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 - (55 if uiscale is bui.UIScale.SMALL else 44),
 145            ),
 146            size=(0, 0),
 147            color=app.ui_v1.title_color,
 148            scale=1.1 if uiscale is bui.UIScale.SMALL else 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            border_opacity=0.4,
 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                        bui.screenmessage(
 541                            bui.Lstr(resource='notEnoughTicketsText'),
 542                            color=(1, 0, 0),
 543                        )
 544                        # gettickets.show_get_tickets_prompt()
 545                    else:
 546
 547                        def do_it() -> None:
 548                            self._do_purchase_check(
 549                                item, is_ticket_purchase=True
 550                            )
 551
 552                        bui.getsound('swish').play()
 553                        ConfirmWindow(
 554                            bui.Lstr(
 555                                resource='store.purchaseConfirmText',
 556                                subs=[
 557                                    (
 558                                        '${ITEM}',
 559                                        store.get_store_item_name_translated(
 560                                            item
 561                                        ),
 562                                    )
 563                                ],
 564                            ),
 565                            width=400,
 566                            height=120,
 567                            action=do_it,
 568                            ok_text=bui.Lstr(
 569                                resource='store.purchaseText',
 570                                fallback_resource='okText',
 571                            ),
 572                        )
 573
 574    def _print_already_own(self, charname: str) -> None:
 575        bui.screenmessage(
 576            bui.Lstr(
 577                resource=f'{self._r}.alreadyOwnText',
 578                subs=[('${NAME}', charname)],
 579            ),
 580            color=(1, 0, 0),
 581        )
 582        bui.getsound('error').play()
 583
 584    def update_buttons(self) -> None:
 585        """Update our buttons."""
 586        # pylint: disable=too-many-statements
 587        # pylint: disable=too-many-branches
 588        # pylint: disable=too-many-locals
 589        from bauiv1 import SpecialChar
 590
 591        assert bui.app.classic is not None
 592        store = bui.app.classic.store
 593
 594        plus = bui.app.plus
 595        assert plus is not None
 596
 597        if not self._root_widget:
 598            return
 599
 600        sales_raw = plus.get_v1_account_misc_read_val('sales', {})
 601        sales = {}
 602        try:
 603            # Look at the current set of sales; filter any with time remaining.
 604            for sale_item, sale_info in list(sales_raw.items()):
 605                to_end = (
 606                    datetime.datetime.fromtimestamp(
 607                        sale_info['e'], datetime.UTC
 608                    )
 609                    - utc_now()
 610                ).total_seconds()
 611                if to_end > 0:
 612                    sales[sale_item] = {
 613                        'to_end': to_end,
 614                        'original_price': sale_info['op'],
 615                    }
 616        except Exception:
 617            logging.exception('Error parsing sales.')
 618
 619        assert self.button_infos is not None
 620        for b_type, b_info in self.button_infos.items():
 621            if b_type == 'merch':
 622                purchased = False
 623            elif b_type in ['upgrades.pro', 'pro']:
 624                assert bui.app.classic is not None
 625                purchased = bui.app.classic.accounts.have_pro()
 626            else:
 627                purchased = plus.get_v1_account_product_purchased(b_type)
 628
 629            sale_opacity = 0.0
 630            sale_title_text: str | bui.Lstr = ''
 631            sale_time_text: str | bui.Lstr = ''
 632
 633            call: Callable | None
 634            if purchased:
 635                title_color = (0.8, 0.7, 0.9, 1.0)
 636                color = (0.63, 0.55, 0.78)
 637                extra_image_opacity = 0.5
 638                call = bui.WeakCall(self._print_already_own, b_info['name'])
 639                price_text = ''
 640                price_text_left = ''
 641                price_text_right = ''
 642                show_purchase_check = True
 643                description_color: Sequence[float] = (0.4, 1.0, 0.4, 0.4)
 644                description_color2: Sequence[float] = (0.0, 0.0, 0.0, 0.0)
 645                price_color = (0.5, 1, 0.5, 0.3)
 646            else:
 647                title_color = (0.7, 0.9, 0.7, 1.0)
 648                color = (0.4, 0.8, 0.1)
 649                extra_image_opacity = 1.0
 650                call = b_info['call'] if 'call' in b_info else None
 651                if b_type == 'merch':
 652                    price_text = ''
 653                    price_text_left = ''
 654                    price_text_right = ''
 655                elif b_type in ['upgrades.pro', 'pro']:
 656                    sale_time = store.get_available_sale_time('extras')
 657                    if sale_time is not None:
 658                        priceraw = plus.get_price('pro')
 659                        price_text_left = (
 660                            priceraw if priceraw is not None else '?'
 661                        )
 662                        priceraw = plus.get_price('pro_sale')
 663                        price_text_right = (
 664                            priceraw if priceraw is not None else '?'
 665                        )
 666                        sale_opacity = 1.0
 667                        price_text = ''
 668                        sale_title_text = bui.Lstr(resource='store.saleText')
 669                        sale_time_text = bui.timestring(
 670                            sale_time / 1000.0, centi=False
 671                        )
 672                    else:
 673                        priceraw = plus.get_price('pro')
 674                        price_text = priceraw if priceraw is not None else '?'
 675                        price_text_left = ''
 676                        price_text_right = ''
 677                else:
 678                    price = plus.get_v1_account_misc_read_val(
 679                        'price.' + b_type, 0
 680                    )
 681
 682                    # Color the button differently if we cant afford this.
 683                    if plus.get_v1_account_state() == 'signed_in':
 684                        if plus.get_v1_account_ticket_count() < price:
 685                            color = (0.6, 0.61, 0.6)
 686                    price_text = bui.charstr(bui.SpecialChar.TICKET) + str(
 687                        plus.get_v1_account_misc_read_val(
 688                            'price.' + b_type, '?'
 689                        )
 690                    )
 691                    price_text_left = ''
 692                    price_text_right = ''
 693
 694                    # TESTING:
 695                    if b_type in sales:
 696                        sale_opacity = 1.0
 697                        price_text_left = bui.charstr(SpecialChar.TICKET) + str(
 698                            sales[b_type]['original_price']
 699                        )
 700                        price_text_right = price_text
 701                        price_text = ''
 702                        sale_title_text = bui.Lstr(resource='store.saleText')
 703                        sale_time_text = bui.timestring(
 704                            sales[b_type]['to_end'], centi=False
 705                        )
 706
 707                description_color = (0.5, 1.0, 0.5)
 708                description_color2 = (0.3, 1.0, 1.0)
 709                price_color = (0.2, 1, 0.2, 1.0)
 710                show_purchase_check = False
 711
 712            if 'title_text' in b_info:
 713                bui.textwidget(edit=b_info['title_text'], color=title_color)
 714            if 'purchase_check' in b_info:
 715                bui.imagewidget(
 716                    edit=b_info['purchase_check'],
 717                    opacity=1.0 if show_purchase_check else 0.0,
 718                )
 719            if 'price_widget' in b_info:
 720                bui.textwidget(
 721                    edit=b_info['price_widget'],
 722                    text=price_text,
 723                    color=price_color,
 724                )
 725            if 'price_widget_left' in b_info:
 726                bui.textwidget(
 727                    edit=b_info['price_widget_left'], text=price_text_left
 728                )
 729            if 'price_widget_right' in b_info:
 730                bui.textwidget(
 731                    edit=b_info['price_widget_right'], text=price_text_right
 732                )
 733            if 'price_slash_widget' in b_info:
 734                bui.imagewidget(
 735                    edit=b_info['price_slash_widget'], opacity=sale_opacity
 736                )
 737            if 'sale_bg_widget' in b_info:
 738                bui.imagewidget(
 739                    edit=b_info['sale_bg_widget'], opacity=sale_opacity
 740                )
 741            if 'sale_title_widget' in b_info:
 742                bui.textwidget(
 743                    edit=b_info['sale_title_widget'], text=sale_title_text
 744                )
 745            if 'sale_time_widget' in b_info:
 746                bui.textwidget(
 747                    edit=b_info['sale_time_widget'], text=sale_time_text
 748                )
 749            if 'button' in b_info:
 750                bui.buttonwidget(
 751                    edit=b_info['button'], color=color, on_activate_call=call
 752                )
 753            if 'extra_backings' in b_info:
 754                for bck in b_info['extra_backings']:
 755                    bui.imagewidget(
 756                        edit=bck, color=color, opacity=extra_image_opacity
 757                    )
 758            if 'extra_images' in b_info:
 759                for img in b_info['extra_images']:
 760                    bui.imagewidget(edit=img, opacity=extra_image_opacity)
 761            if 'extra_texts' in b_info:
 762                for etxt in b_info['extra_texts']:
 763                    bui.textwidget(edit=etxt, color=description_color)
 764            if 'extra_texts_2' in b_info:
 765                for etxt in b_info['extra_texts_2']:
 766                    bui.textwidget(edit=etxt, color=description_color2)
 767            if 'descriptionText' in b_info:
 768                bui.textwidget(
 769                    edit=b_info['descriptionText'], color=description_color
 770                )
 771
 772    def _on_response(self, data: dict[str, Any] | None) -> None:
 773        # pylint: disable=too-many-statements
 774
 775        assert bui.app.classic is not None
 776        cstore = bui.app.classic.store
 777
 778        # clear status text..
 779        if self._status_textwidget:
 780            self._status_textwidget.delete()
 781            self._status_textwidget_update_timer = None
 782
 783        if data is None:
 784            self._status_textwidget = bui.textwidget(
 785                parent=self._root_widget,
 786                position=(self._width * 0.5, self._height * 0.5),
 787                size=(0, 0),
 788                scale=1.3,
 789                transition_delay=0.1,
 790                color=(1, 0.3, 0.3, 1.0),
 791                h_align='center',
 792                v_align='center',
 793                text=bui.Lstr(resource=f'{self._r}.loadErrorText'),
 794                maxwidth=self._scroll_width * 0.9,
 795            )
 796        else:
 797
 798            class _Store:
 799                def __init__(
 800                    self,
 801                    store_window: StoreBrowserWindow,
 802                    sdata: dict[str, Any],
 803                    width: float,
 804                ):
 805                    self._store_window = store_window
 806                    self._width = width
 807                    store_data = cstore.get_store_layout()
 808                    self._tab = sdata['tab']
 809                    self._sections = copy.deepcopy(store_data[sdata['tab']])
 810                    self._height: float | None = None
 811
 812                    assert bui.app.classic is not None
 813                    uiscale = bui.app.ui_v1.uiscale
 814
 815                    # Pre-calc a few things and add them to store-data.
 816                    for section in self._sections:
 817                        if self._tab == 'characters':
 818                            dummy_name = 'characters.foo'
 819                        elif self._tab == 'extras':
 820                            dummy_name = 'pro'
 821                        elif self._tab == 'maps':
 822                            dummy_name = 'maps.foo'
 823                        elif self._tab == 'icons':
 824                            dummy_name = 'icons.foo'
 825                        else:
 826                            dummy_name = ''
 827                        section['button_size'] = (
 828                            cstore.get_store_item_display_size(dummy_name)
 829                        )
 830                        section['v_spacing'] = (
 831                            -25
 832                            if (
 833                                self._tab == 'extras'
 834                                and uiscale is bui.UIScale.SMALL
 835                            )
 836                            else -17 if self._tab == 'characters' else 0
 837                        )
 838                        if 'title' not in section:
 839                            section['title'] = ''
 840                        section['x_offs'] = (
 841                            130
 842                            if self._tab == 'extras'
 843                            else 270 if self._tab == 'maps' else 0
 844                        )
 845                        section['y_offs'] = (
 846                            20
 847                            if (
 848                                self._tab == 'extras'
 849                                and uiscale is bui.UIScale.SMALL
 850                                and bui.app.config.get('Merch Link')
 851                            )
 852                            else (
 853                                55
 854                                if (
 855                                    self._tab == 'extras'
 856                                    and uiscale is bui.UIScale.SMALL
 857                                )
 858                                else -20 if self._tab == 'icons' else 0
 859                            )
 860                        )
 861
 862                def instantiate(
 863                    self, scrollwidget: bui.Widget, tab_button: bui.Widget
 864                ) -> None:
 865                    """Create the store."""
 866                    # pylint: disable=too-many-locals
 867                    # pylint: disable=too-many-branches
 868                    # pylint: disable=too-many-nested-blocks
 869                    from bauiv1lib.store.item import (
 870                        instantiate_store_item_display,
 871                    )
 872
 873                    title_spacing = 40
 874                    button_border = 20
 875                    button_spacing = 4
 876                    boffs_h = 40
 877                    self._height = 80.0
 878
 879                    # Calc total height.
 880                    for i, section in enumerate(self._sections):
 881                        if section['title'] != '':
 882                            assert self._height is not None
 883                            self._height += title_spacing
 884                        b_width, b_height = section['button_size']
 885                        b_column_count = int(
 886                            math.floor(
 887                                (self._width - boffs_h - 20)
 888                                / (b_width + button_spacing)
 889                            )
 890                        )
 891                        b_row_count = int(
 892                            math.ceil(
 893                                float(len(section['items'])) / b_column_count
 894                            )
 895                        )
 896                        b_height_total = (
 897                            2 * button_border
 898                            + b_row_count * b_height
 899                            + (b_row_count - 1) * section['v_spacing']
 900                        )
 901                        self._height += b_height_total
 902
 903                    assert self._height is not None
 904                    cnt2 = bui.containerwidget(
 905                        parent=scrollwidget,
 906                        scale=1.0,
 907                        size=(self._width, self._height),
 908                        background=False,
 909                        claims_left_right=True,
 910                        selection_loops_to_parent=True,
 911                    )
 912                    v = self._height - 20
 913
 914                    if self._tab == 'characters':
 915                        txt = bui.Lstr(
 916                            resource='store.howToSwitchCharactersText',
 917                            subs=[
 918                                (
 919                                    '${SETTINGS}',
 920                                    bui.Lstr(
 921                                        resource=(
 922                                            'accountSettingsWindow.titleText'
 923                                        )
 924                                    ),
 925                                ),
 926                                (
 927                                    '${PLAYER_PROFILES}',
 928                                    bui.Lstr(
 929                                        resource=(
 930                                            'playerProfilesWindow.titleText'
 931                                        )
 932                                    ),
 933                                ),
 934                            ],
 935                        )
 936                        bui.textwidget(
 937                            parent=cnt2,
 938                            text=txt,
 939                            size=(0, 0),
 940                            position=(self._width * 0.5, self._height - 28),
 941                            h_align='center',
 942                            v_align='center',
 943                            color=(0.7, 1, 0.7, 0.4),
 944                            scale=0.7,
 945                            shadow=0,
 946                            flatness=1.0,
 947                            maxwidth=700,
 948                            transition_delay=0.4,
 949                        )
 950                    elif self._tab == 'icons':
 951                        txt = bui.Lstr(
 952                            resource='store.howToUseIconsText',
 953                            subs=[
 954                                (
 955                                    '${SETTINGS}',
 956                                    bui.Lstr(resource='mainMenu.settingsText'),
 957                                ),
 958                                (
 959                                    '${PLAYER_PROFILES}',
 960                                    bui.Lstr(
 961                                        resource=(
 962                                            'playerProfilesWindow.titleText'
 963                                        )
 964                                    ),
 965                                ),
 966                            ],
 967                        )
 968                        bui.textwidget(
 969                            parent=cnt2,
 970                            text=txt,
 971                            size=(0, 0),
 972                            position=(self._width * 0.5, self._height - 28),
 973                            h_align='center',
 974                            v_align='center',
 975                            color=(0.7, 1, 0.7, 0.4),
 976                            scale=0.7,
 977                            shadow=0,
 978                            flatness=1.0,
 979                            maxwidth=700,
 980                            transition_delay=0.4,
 981                        )
 982                    elif self._tab == 'maps':
 983                        assert self._width is not None
 984                        assert self._height is not None
 985                        txt = bui.Lstr(resource='store.howToUseMapsText')
 986                        bui.textwidget(
 987                            parent=cnt2,
 988                            text=txt,
 989                            size=(0, 0),
 990                            position=(self._width * 0.5, self._height - 28),
 991                            h_align='center',
 992                            v_align='center',
 993                            color=(0.7, 1, 0.7, 0.4),
 994                            scale=0.7,
 995                            shadow=0,
 996                            flatness=1.0,
 997                            maxwidth=700,
 998                            transition_delay=0.4,
 999                        )
1000
1001                    prev_row_buttons: list | None = None
1002                    this_row_buttons = []
1003
1004                    delay = 0.3
1005                    for section in self._sections:
1006                        if section['title'] != '':
1007                            bui.textwidget(
1008                                parent=cnt2,
1009                                position=(60, v - title_spacing * 0.8),
1010                                size=(0, 0),
1011                                scale=1.0,
1012                                transition_delay=delay,
1013                                color=(0.7, 0.9, 0.7, 1),
1014                                h_align='left',
1015                                v_align='center',
1016                                text=bui.Lstr(resource=section['title']),
1017                                maxwidth=self._width * 0.7,
1018                            )
1019                            v -= title_spacing
1020                        delay = max(0.100, delay - 0.100)
1021                        v -= button_border
1022                        b_width, b_height = section['button_size']
1023                        b_count = len(section['items'])
1024                        b_column_count = int(
1025                            math.floor(
1026                                (self._width - boffs_h - 20)
1027                                / (b_width + button_spacing)
1028                            )
1029                        )
1030                        col = 0
1031                        item: dict[str, Any]
1032                        assert self._store_window.button_infos is not None
1033                        for i, item_name in enumerate(section['items']):
1034                            item = self._store_window.button_infos[
1035                                item_name
1036                            ] = {}
1037                            item['call'] = bui.WeakCall(
1038                                self._store_window.buy, item_name
1039                            )
1040                            if 'x_offs' in section:
1041                                boffs_h2 = section['x_offs']
1042                            else:
1043                                boffs_h2 = 0
1044
1045                            if 'y_offs' in section:
1046                                boffs_v2 = section['y_offs']
1047                            else:
1048                                boffs_v2 = 0
1049                            b_pos = (
1050                                boffs_h
1051                                + boffs_h2
1052                                + (b_width + button_spacing) * col,
1053                                v - b_height + boffs_v2,
1054                            )
1055                            instantiate_store_item_display(
1056                                item_name,
1057                                item,
1058                                parent_widget=cnt2,
1059                                b_pos=b_pos,
1060                                boffs_h=boffs_h,
1061                                b_width=b_width,
1062                                b_height=b_height,
1063                                boffs_h2=boffs_h2,
1064                                boffs_v2=boffs_v2,
1065                                delay=delay,
1066                            )
1067                            btn = item['button']
1068                            delay = max(0.1, delay - 0.1)
1069                            this_row_buttons.append(btn)
1070
1071                            # Wire this button to the equivalent in the
1072                            # previous row.
1073                            if prev_row_buttons is not None:
1074                                if len(prev_row_buttons) > col:
1075                                    bui.widget(
1076                                        edit=btn,
1077                                        up_widget=prev_row_buttons[col],
1078                                    )
1079                                    bui.widget(
1080                                        edit=prev_row_buttons[col],
1081                                        down_widget=btn,
1082                                    )
1083
1084                                    # If we're the last button in our row,
1085                                    # wire any in the previous row past
1086                                    # our position to go to us if down is
1087                                    # pressed.
1088                                    if (
1089                                        col + 1 == b_column_count
1090                                        or i == b_count - 1
1091                                    ):
1092                                        for b_prev in prev_row_buttons[
1093                                            col + 1 :
1094                                        ]:
1095                                            bui.widget(
1096                                                edit=b_prev, down_widget=btn
1097                                            )
1098                                else:
1099                                    bui.widget(
1100                                        edit=btn, up_widget=prev_row_buttons[-1]
1101                                    )
1102                            else:
1103                                bui.widget(edit=btn, up_widget=tab_button)
1104
1105                            col += 1
1106                            if col == b_column_count or i == b_count - 1:
1107                                prev_row_buttons = this_row_buttons
1108                                this_row_buttons = []
1109                                col = 0
1110                                v -= b_height
1111                                if i < b_count - 1:
1112                                    v -= section['v_spacing']
1113
1114                        v -= button_border
1115
1116                    # Set a timer to update these buttons periodically as long
1117                    # as we're alive (so if we buy one it will grey out, etc).
1118                    self._store_window.update_buttons_timer = bui.AppTimer(
1119                        0.5,
1120                        bui.WeakCall(self._store_window.update_buttons),
1121                        repeat=True,
1122                    )
1123
1124                    # Also update them immediately.
1125                    self._store_window.update_buttons()
1126
1127            if self._current_tab in (
1128                self.TabID.EXTRAS,
1129                self.TabID.MINIGAMES,
1130                self.TabID.CHARACTERS,
1131                self.TabID.MAPS,
1132                self.TabID.ICONS,
1133            ):
1134                store = _Store(self, data, self._scroll_width)
1135                assert self._scrollwidget is not None
1136                store.instantiate(
1137                    scrollwidget=self._scrollwidget,
1138                    tab_button=self._tab_row.tabs[self._current_tab].button,
1139                )
1140            else:
1141                cnt = bui.containerwidget(
1142                    parent=self._scrollwidget,
1143                    scale=1.0,
1144                    size=(self._scroll_width, self._scroll_height * 0.95),
1145                    background=False,
1146                    claims_left_right=True,
1147                    selection_loops_to_parent=True,
1148                )
1149                self._status_textwidget = bui.textwidget(
1150                    parent=cnt,
1151                    position=(
1152                        self._scroll_width * 0.5,
1153                        self._scroll_height * 0.5,
1154                    ),
1155                    size=(0, 0),
1156                    scale=1.3,
1157                    transition_delay=0.1,
1158                    color=(1, 1, 0.3, 1.0),
1159                    h_align='center',
1160                    v_align='center',
1161                    text=bui.Lstr(resource=f'{self._r}.comingSoonText'),
1162                    maxwidth=self._scroll_width * 0.9,
1163                )
1164
1165    @override
1166    def get_main_window_state(self) -> bui.MainWindowState:
1167        # Support recreating our window for back/refresh purposes.
1168        cls = type(self)
1169        return bui.BasicMainWindowState(
1170            create_call=lambda transition, origin_widget: cls(
1171                transition=transition, origin_widget=origin_widget
1172            )
1173        )
1174
1175    @override
1176    def on_main_window_close(self) -> None:
1177        self._save_state()
1178
1179    def _save_state(self) -> None:
1180        try:
1181            sel = self._root_widget.get_selected_child()
1182            selected_tab_ids = [
1183                tab_id
1184                for tab_id, tab in self._tab_row.tabs.items()
1185                if sel == tab.button
1186            ]
1187            if sel == self._scrollwidget:
1188                sel_name = 'Scroll'
1189            elif sel == self._back_button:
1190                sel_name = 'Back'
1191            elif selected_tab_ids:
1192                assert len(selected_tab_ids) == 1
1193                sel_name = f'Tab:{selected_tab_ids[0].value}'
1194            else:
1195                raise ValueError(f'unrecognized selection \'{sel}\'')
1196            assert bui.app.classic is not None
1197            bui.app.ui_v1.window_states[type(self)] = {
1198                'sel_name': sel_name,
1199            }
1200        except Exception:
1201            logging.exception('Error saving state for %s.', self)
1202
1203    def _restore_state(self) -> None:
1204
1205        try:
1206            sel: bui.Widget | None
1207            assert bui.app.classic is not None
1208            sel_name = bui.app.ui_v1.window_states.get(type(self), {}).get(
1209                'sel_name'
1210            )
1211            assert isinstance(sel_name, (str, type(None)))
1212
1213            try:
1214                current_tab = self.TabID(bui.app.config.get('Store Tab'))
1215            except ValueError:
1216                current_tab = self.TabID.CHARACTERS
1217
1218            if self._show_tab is not None:
1219                current_tab = self._show_tab
1220            if sel_name == 'Back':
1221                sel = self._back_button
1222            elif sel_name == 'Scroll':
1223                sel = self._scrollwidget
1224            elif isinstance(sel_name, str) and sel_name.startswith('Tab:'):
1225                try:
1226                    sel_tab_id = self.TabID(sel_name.split(':')[-1])
1227                except ValueError:
1228                    sel_tab_id = self.TabID.CHARACTERS
1229                sel = self._tab_row.tabs[sel_tab_id].button
1230            else:
1231                sel = self._tab_row.tabs[current_tab].button
1232
1233            # If we were requested to show a tab, select it too.
1234            if (
1235                self._show_tab is not None
1236                and self._show_tab in self._tab_row.tabs
1237            ):
1238                sel = self._tab_row.tabs[self._show_tab].button
1239            self._set_tab(current_tab)
1240            if sel is not None:
1241                bui.containerwidget(edit=self._root_widget, selected_child=sel)
1242        except Exception:
1243            logging.exception('Error restoring state for %s.', self)
1244
1245
1246def _check_merch_availability_in_bg_thread() -> None:
1247    # pylint: disable=cell-var-from-loop
1248
1249    # Merch is available from some countries only. Make a reasonable
1250    # check to ask the master-server about this at launch and store the
1251    # results.
1252    plus = bui.app.plus
1253    assert plus is not None
1254
1255    for _i in range(15):
1256        try:
1257            if plus.cloud.is_connected():
1258                response = plus.cloud.send_message(
1259                    bacommon.cloud.MerchAvailabilityMessage()
1260                )
1261
1262                def _store_in_logic_thread() -> None:
1263                    cfg = bui.app.config
1264                    current = cfg.get(MERCH_LINK_KEY)
1265                    if not isinstance(current, str | None):
1266                        current = None
1267                    if current != response.url:
1268                        cfg[MERCH_LINK_KEY] = response.url
1269                        cfg.commit()
1270
1271                # If we successfully get a response, kick it over to the
1272                # logic thread to store and we're done.
1273                bui.pushcall(_store_in_logic_thread, from_other_thread=True)
1274                return
1275        except CommunicationError:
1276            pass
1277        except Exception:
1278            logging.warning(
1279                'Unexpected error in merch-availability-check.', exc_info=True
1280            )
1281        time.sleep(1.1934)  # A bit randomized to avoid aliasing.
1282
1283
1284# Slight hack; start checking merch availability in the bg (but only if
1285# it looks like we've been imported for use in a running app; don't want
1286# to do this during docs generation/etc.)
1287
1288# TODO: Should wire this up explicitly to app bootstrapping; not good to
1289# be kicking off work at module import time.
1290if (
1291    os.environ.get('BA_RUNNING_WITH_DUMMY_MODULES') != '1'
1292    and bui.app.state is not bui.app.State.NOT_STARTED
1293):
1294    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 - (55 if uiscale is bui.UIScale.SMALL else 44),
 146            ),
 147            size=(0, 0),
 148            color=app.ui_v1.title_color,
 149            scale=1.1 if uiscale is bui.UIScale.SMALL else 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            border_opacity=0.4,
 355        )
 356
 357        # NOTE: this stuff is modified by the _Store class.
 358        # Should maybe clean that up.
 359        self.button_infos = {}
 360        self.update_buttons_timer = None
 361
 362        # Show status over top.
 363        if self._status_textwidget:
 364            self._status_textwidget.delete()
 365        self._status_textwidget = bui.textwidget(
 366            parent=self._root_widget,
 367            position=(self._width * 0.5, self._height * 0.5),
 368            size=(0, 0),
 369            color=(1, 0.7, 1, 0.5),
 370            h_align='center',
 371            v_align='center',
 372            text=bui.Lstr(resource=f'{self._r}.loadingText'),
 373            maxwidth=self._scroll_width * 0.9,
 374        )
 375
 376        class _Request:
 377            def __init__(self, window: StoreBrowserWindow):
 378                self._window = weakref.ref(window)
 379                data = {'tab': tab_id.value}
 380                bui.apptimer(0.1, bui.WeakCall(self._on_response, data))
 381
 382            def _on_response(self, data: dict[str, Any] | None) -> None:
 383                # FIXME: clean this up.
 384                # pylint: disable=protected-access
 385                window = self._window()
 386                if window is not None and (window.request is self):
 387                    window.request = None
 388                    window._on_response(data)
 389
 390        # Kick off a server request.
 391        self.request = _Request(self)
 392
 393    # Actually start the purchase locally.
 394    def _purchase_check_result(
 395        self, item: str, is_ticket_purchase: bool, result: dict[str, Any] | None
 396    ) -> None:
 397        plus = bui.app.plus
 398        assert plus is not None
 399        if result is None:
 400            bui.getsound('error').play()
 401            bui.screenmessage(
 402                bui.Lstr(resource='internal.unavailableNoConnectionText'),
 403                color=(1, 0, 0),
 404            )
 405        else:
 406            if is_ticket_purchase:
 407                if result['allow']:
 408                    price = plus.get_v1_account_misc_read_val(
 409                        'price.' + item, None
 410                    )
 411                    if (
 412                        price is None
 413                        or not isinstance(price, int)
 414                        or price <= 0
 415                    ):
 416                        print(
 417                            'Error; got invalid local price of',
 418                            price,
 419                            'for item',
 420                            item,
 421                        )
 422                        bui.getsound('error').play()
 423                    else:
 424                        bui.getsound('click01').play()
 425                        plus.in_game_purchase(item, price)
 426                else:
 427                    if result['reason'] == 'versionTooOld':
 428                        bui.getsound('error').play()
 429                        bui.screenmessage(
 430                            bui.Lstr(
 431                                resource='getTicketsWindow.versionTooOldText'
 432                            ),
 433                            color=(1, 0, 0),
 434                        )
 435                    else:
 436                        bui.getsound('error').play()
 437                        bui.screenmessage(
 438                            bui.Lstr(
 439                                resource='getTicketsWindow.unavailableText'
 440                            ),
 441                            color=(1, 0, 0),
 442                        )
 443            # Real in-app purchase.
 444            else:
 445                if result['allow']:
 446                    plus.purchase(item)
 447                else:
 448                    if result['reason'] == 'versionTooOld':
 449                        bui.getsound('error').play()
 450                        bui.screenmessage(
 451                            bui.Lstr(
 452                                resource='getTicketsWindow.versionTooOldText'
 453                            ),
 454                            color=(1, 0, 0),
 455                        )
 456                    else:
 457                        bui.getsound('error').play()
 458                        bui.screenmessage(
 459                            bui.Lstr(
 460                                resource='getTicketsWindow.unavailableText'
 461                            ),
 462                            color=(1, 0, 0),
 463                        )
 464
 465    def _do_purchase_check(
 466        self, item: str, is_ticket_purchase: bool = False
 467    ) -> None:
 468        app = bui.app
 469        if app.classic is None:
 470            logging.warning('_do_purchase_check() requires classic.')
 471            return
 472
 473        # Here we ping the server to ask if it's valid for us to
 474        # purchase this. Better to fail now than after we've
 475        # paid locally.
 476
 477        app.classic.master_server_v1_get(
 478            'bsAccountPurchaseCheck',
 479            {
 480                'item': item,
 481                'platform': app.classic.platform,
 482                'subplatform': app.classic.subplatform,
 483                'version': app.env.engine_version,
 484                'buildNumber': app.env.engine_build_number,
 485                'purchaseType': 'ticket' if is_ticket_purchase else 'real',
 486            },
 487            callback=bui.WeakCall(
 488                self._purchase_check_result, item, is_ticket_purchase
 489            ),
 490        )
 491
 492    def buy(self, item: str) -> None:
 493        """Attempt to purchase the provided item."""
 494        from bauiv1lib.account.signin import show_sign_in_prompt
 495        from bauiv1lib.confirm import ConfirmWindow
 496
 497        assert bui.app.classic is not None
 498        store = bui.app.classic.store
 499
 500        plus = bui.app.plus
 501        assert plus is not None
 502
 503        # Prevent pressing buy within a few seconds of the last press
 504        # (gives the buttons time to disable themselves and whatnot).
 505        curtime = bui.apptime()
 506        if (
 507            self._last_buy_time is not None
 508            and (curtime - self._last_buy_time) < 2.0
 509        ):
 510            bui.getsound('error').play()
 511        else:
 512            if plus.get_v1_account_state() != 'signed_in':
 513                show_sign_in_prompt()
 514            else:
 515                self._last_buy_time = curtime
 516
 517                # Merch is a special case - just a link.
 518                if item == 'merch':
 519                    url = bui.app.config.get('Merch Link')
 520                    if isinstance(url, str):
 521                        bui.open_url(url)
 522
 523                # Pro is an actual IAP, and the rest are ticket purchases.
 524                elif item == 'pro':
 525                    bui.getsound('click01').play()
 526
 527                    # Purchase either pro or pro_sale depending on whether
 528                    # there is a sale going on.
 529                    self._do_purchase_check(
 530                        'pro'
 531                        if store.get_available_sale_time('extras') is None
 532                        else 'pro_sale'
 533                    )
 534                else:
 535                    price = plus.get_v1_account_misc_read_val(
 536                        'price.' + item, None
 537                    )
 538                    our_tickets = plus.get_v1_account_ticket_count()
 539                    if price is not None and our_tickets < price:
 540                        bui.getsound('error').play()
 541                        bui.screenmessage(
 542                            bui.Lstr(resource='notEnoughTicketsText'),
 543                            color=(1, 0, 0),
 544                        )
 545                        # gettickets.show_get_tickets_prompt()
 546                    else:
 547
 548                        def do_it() -> None:
 549                            self._do_purchase_check(
 550                                item, is_ticket_purchase=True
 551                            )
 552
 553                        bui.getsound('swish').play()
 554                        ConfirmWindow(
 555                            bui.Lstr(
 556                                resource='store.purchaseConfirmText',
 557                                subs=[
 558                                    (
 559                                        '${ITEM}',
 560                                        store.get_store_item_name_translated(
 561                                            item
 562                                        ),
 563                                    )
 564                                ],
 565                            ),
 566                            width=400,
 567                            height=120,
 568                            action=do_it,
 569                            ok_text=bui.Lstr(
 570                                resource='store.purchaseText',
 571                                fallback_resource='okText',
 572                            ),
 573                        )
 574
 575    def _print_already_own(self, charname: str) -> None:
 576        bui.screenmessage(
 577            bui.Lstr(
 578                resource=f'{self._r}.alreadyOwnText',
 579                subs=[('${NAME}', charname)],
 580            ),
 581            color=(1, 0, 0),
 582        )
 583        bui.getsound('error').play()
 584
 585    def update_buttons(self) -> None:
 586        """Update our buttons."""
 587        # pylint: disable=too-many-statements
 588        # pylint: disable=too-many-branches
 589        # pylint: disable=too-many-locals
 590        from bauiv1 import SpecialChar
 591
 592        assert bui.app.classic is not None
 593        store = bui.app.classic.store
 594
 595        plus = bui.app.plus
 596        assert plus is not None
 597
 598        if not self._root_widget:
 599            return
 600
 601        sales_raw = plus.get_v1_account_misc_read_val('sales', {})
 602        sales = {}
 603        try:
 604            # Look at the current set of sales; filter any with time remaining.
 605            for sale_item, sale_info in list(sales_raw.items()):
 606                to_end = (
 607                    datetime.datetime.fromtimestamp(
 608                        sale_info['e'], datetime.UTC
 609                    )
 610                    - utc_now()
 611                ).total_seconds()
 612                if to_end > 0:
 613                    sales[sale_item] = {
 614                        'to_end': to_end,
 615                        'original_price': sale_info['op'],
 616                    }
 617        except Exception:
 618            logging.exception('Error parsing sales.')
 619
 620        assert self.button_infos is not None
 621        for b_type, b_info in self.button_infos.items():
 622            if b_type == 'merch':
 623                purchased = False
 624            elif b_type in ['upgrades.pro', 'pro']:
 625                assert bui.app.classic is not None
 626                purchased = bui.app.classic.accounts.have_pro()
 627            else:
 628                purchased = plus.get_v1_account_product_purchased(b_type)
 629
 630            sale_opacity = 0.0
 631            sale_title_text: str | bui.Lstr = ''
 632            sale_time_text: str | bui.Lstr = ''
 633
 634            call: Callable | None
 635            if purchased:
 636                title_color = (0.8, 0.7, 0.9, 1.0)
 637                color = (0.63, 0.55, 0.78)
 638                extra_image_opacity = 0.5
 639                call = bui.WeakCall(self._print_already_own, b_info['name'])
 640                price_text = ''
 641                price_text_left = ''
 642                price_text_right = ''
 643                show_purchase_check = True
 644                description_color: Sequence[float] = (0.4, 1.0, 0.4, 0.4)
 645                description_color2: Sequence[float] = (0.0, 0.0, 0.0, 0.0)
 646                price_color = (0.5, 1, 0.5, 0.3)
 647            else:
 648                title_color = (0.7, 0.9, 0.7, 1.0)
 649                color = (0.4, 0.8, 0.1)
 650                extra_image_opacity = 1.0
 651                call = b_info['call'] if 'call' in b_info else None
 652                if b_type == 'merch':
 653                    price_text = ''
 654                    price_text_left = ''
 655                    price_text_right = ''
 656                elif b_type in ['upgrades.pro', 'pro']:
 657                    sale_time = store.get_available_sale_time('extras')
 658                    if sale_time is not None:
 659                        priceraw = plus.get_price('pro')
 660                        price_text_left = (
 661                            priceraw if priceraw is not None else '?'
 662                        )
 663                        priceraw = plus.get_price('pro_sale')
 664                        price_text_right = (
 665                            priceraw if priceraw is not None else '?'
 666                        )
 667                        sale_opacity = 1.0
 668                        price_text = ''
 669                        sale_title_text = bui.Lstr(resource='store.saleText')
 670                        sale_time_text = bui.timestring(
 671                            sale_time / 1000.0, centi=False
 672                        )
 673                    else:
 674                        priceraw = plus.get_price('pro')
 675                        price_text = priceraw if priceraw is not None else '?'
 676                        price_text_left = ''
 677                        price_text_right = ''
 678                else:
 679                    price = plus.get_v1_account_misc_read_val(
 680                        'price.' + b_type, 0
 681                    )
 682
 683                    # Color the button differently if we cant afford this.
 684                    if plus.get_v1_account_state() == 'signed_in':
 685                        if plus.get_v1_account_ticket_count() < price:
 686                            color = (0.6, 0.61, 0.6)
 687                    price_text = bui.charstr(bui.SpecialChar.TICKET) + str(
 688                        plus.get_v1_account_misc_read_val(
 689                            'price.' + b_type, '?'
 690                        )
 691                    )
 692                    price_text_left = ''
 693                    price_text_right = ''
 694
 695                    # TESTING:
 696                    if b_type in sales:
 697                        sale_opacity = 1.0
 698                        price_text_left = bui.charstr(SpecialChar.TICKET) + str(
 699                            sales[b_type]['original_price']
 700                        )
 701                        price_text_right = price_text
 702                        price_text = ''
 703                        sale_title_text = bui.Lstr(resource='store.saleText')
 704                        sale_time_text = bui.timestring(
 705                            sales[b_type]['to_end'], centi=False
 706                        )
 707
 708                description_color = (0.5, 1.0, 0.5)
 709                description_color2 = (0.3, 1.0, 1.0)
 710                price_color = (0.2, 1, 0.2, 1.0)
 711                show_purchase_check = False
 712
 713            if 'title_text' in b_info:
 714                bui.textwidget(edit=b_info['title_text'], color=title_color)
 715            if 'purchase_check' in b_info:
 716                bui.imagewidget(
 717                    edit=b_info['purchase_check'],
 718                    opacity=1.0 if show_purchase_check else 0.0,
 719                )
 720            if 'price_widget' in b_info:
 721                bui.textwidget(
 722                    edit=b_info['price_widget'],
 723                    text=price_text,
 724                    color=price_color,
 725                )
 726            if 'price_widget_left' in b_info:
 727                bui.textwidget(
 728                    edit=b_info['price_widget_left'], text=price_text_left
 729                )
 730            if 'price_widget_right' in b_info:
 731                bui.textwidget(
 732                    edit=b_info['price_widget_right'], text=price_text_right
 733                )
 734            if 'price_slash_widget' in b_info:
 735                bui.imagewidget(
 736                    edit=b_info['price_slash_widget'], opacity=sale_opacity
 737                )
 738            if 'sale_bg_widget' in b_info:
 739                bui.imagewidget(
 740                    edit=b_info['sale_bg_widget'], opacity=sale_opacity
 741                )
 742            if 'sale_title_widget' in b_info:
 743                bui.textwidget(
 744                    edit=b_info['sale_title_widget'], text=sale_title_text
 745                )
 746            if 'sale_time_widget' in b_info:
 747                bui.textwidget(
 748                    edit=b_info['sale_time_widget'], text=sale_time_text
 749                )
 750            if 'button' in b_info:
 751                bui.buttonwidget(
 752                    edit=b_info['button'], color=color, on_activate_call=call
 753                )
 754            if 'extra_backings' in b_info:
 755                for bck in b_info['extra_backings']:
 756                    bui.imagewidget(
 757                        edit=bck, color=color, opacity=extra_image_opacity
 758                    )
 759            if 'extra_images' in b_info:
 760                for img in b_info['extra_images']:
 761                    bui.imagewidget(edit=img, opacity=extra_image_opacity)
 762            if 'extra_texts' in b_info:
 763                for etxt in b_info['extra_texts']:
 764                    bui.textwidget(edit=etxt, color=description_color)
 765            if 'extra_texts_2' in b_info:
 766                for etxt in b_info['extra_texts_2']:
 767                    bui.textwidget(edit=etxt, color=description_color2)
 768            if 'descriptionText' in b_info:
 769                bui.textwidget(
 770                    edit=b_info['descriptionText'], color=description_color
 771                )
 772
 773    def _on_response(self, data: dict[str, Any] | None) -> None:
 774        # pylint: disable=too-many-statements
 775
 776        assert bui.app.classic is not None
 777        cstore = bui.app.classic.store
 778
 779        # clear status text..
 780        if self._status_textwidget:
 781            self._status_textwidget.delete()
 782            self._status_textwidget_update_timer = None
 783
 784        if data is None:
 785            self._status_textwidget = bui.textwidget(
 786                parent=self._root_widget,
 787                position=(self._width * 0.5, self._height * 0.5),
 788                size=(0, 0),
 789                scale=1.3,
 790                transition_delay=0.1,
 791                color=(1, 0.3, 0.3, 1.0),
 792                h_align='center',
 793                v_align='center',
 794                text=bui.Lstr(resource=f'{self._r}.loadErrorText'),
 795                maxwidth=self._scroll_width * 0.9,
 796            )
 797        else:
 798
 799            class _Store:
 800                def __init__(
 801                    self,
 802                    store_window: StoreBrowserWindow,
 803                    sdata: dict[str, Any],
 804                    width: float,
 805                ):
 806                    self._store_window = store_window
 807                    self._width = width
 808                    store_data = cstore.get_store_layout()
 809                    self._tab = sdata['tab']
 810                    self._sections = copy.deepcopy(store_data[sdata['tab']])
 811                    self._height: float | None = None
 812
 813                    assert bui.app.classic is not None
 814                    uiscale = bui.app.ui_v1.uiscale
 815
 816                    # Pre-calc a few things and add them to store-data.
 817                    for section in self._sections:
 818                        if self._tab == 'characters':
 819                            dummy_name = 'characters.foo'
 820                        elif self._tab == 'extras':
 821                            dummy_name = 'pro'
 822                        elif self._tab == 'maps':
 823                            dummy_name = 'maps.foo'
 824                        elif self._tab == 'icons':
 825                            dummy_name = 'icons.foo'
 826                        else:
 827                            dummy_name = ''
 828                        section['button_size'] = (
 829                            cstore.get_store_item_display_size(dummy_name)
 830                        )
 831                        section['v_spacing'] = (
 832                            -25
 833                            if (
 834                                self._tab == 'extras'
 835                                and uiscale is bui.UIScale.SMALL
 836                            )
 837                            else -17 if self._tab == 'characters' else 0
 838                        )
 839                        if 'title' not in section:
 840                            section['title'] = ''
 841                        section['x_offs'] = (
 842                            130
 843                            if self._tab == 'extras'
 844                            else 270 if self._tab == 'maps' else 0
 845                        )
 846                        section['y_offs'] = (
 847                            20
 848                            if (
 849                                self._tab == 'extras'
 850                                and uiscale is bui.UIScale.SMALL
 851                                and bui.app.config.get('Merch Link')
 852                            )
 853                            else (
 854                                55
 855                                if (
 856                                    self._tab == 'extras'
 857                                    and uiscale is bui.UIScale.SMALL
 858                                )
 859                                else -20 if self._tab == 'icons' else 0
 860                            )
 861                        )
 862
 863                def instantiate(
 864                    self, scrollwidget: bui.Widget, tab_button: bui.Widget
 865                ) -> None:
 866                    """Create the store."""
 867                    # pylint: disable=too-many-locals
 868                    # pylint: disable=too-many-branches
 869                    # pylint: disable=too-many-nested-blocks
 870                    from bauiv1lib.store.item import (
 871                        instantiate_store_item_display,
 872                    )
 873
 874                    title_spacing = 40
 875                    button_border = 20
 876                    button_spacing = 4
 877                    boffs_h = 40
 878                    self._height = 80.0
 879
 880                    # Calc total height.
 881                    for i, section in enumerate(self._sections):
 882                        if section['title'] != '':
 883                            assert self._height is not None
 884                            self._height += title_spacing
 885                        b_width, b_height = section['button_size']
 886                        b_column_count = int(
 887                            math.floor(
 888                                (self._width - boffs_h - 20)
 889                                / (b_width + button_spacing)
 890                            )
 891                        )
 892                        b_row_count = int(
 893                            math.ceil(
 894                                float(len(section['items'])) / b_column_count
 895                            )
 896                        )
 897                        b_height_total = (
 898                            2 * button_border
 899                            + b_row_count * b_height
 900                            + (b_row_count - 1) * section['v_spacing']
 901                        )
 902                        self._height += b_height_total
 903
 904                    assert self._height is not None
 905                    cnt2 = bui.containerwidget(
 906                        parent=scrollwidget,
 907                        scale=1.0,
 908                        size=(self._width, self._height),
 909                        background=False,
 910                        claims_left_right=True,
 911                        selection_loops_to_parent=True,
 912                    )
 913                    v = self._height - 20
 914
 915                    if self._tab == 'characters':
 916                        txt = bui.Lstr(
 917                            resource='store.howToSwitchCharactersText',
 918                            subs=[
 919                                (
 920                                    '${SETTINGS}',
 921                                    bui.Lstr(
 922                                        resource=(
 923                                            'accountSettingsWindow.titleText'
 924                                        )
 925                                    ),
 926                                ),
 927                                (
 928                                    '${PLAYER_PROFILES}',
 929                                    bui.Lstr(
 930                                        resource=(
 931                                            'playerProfilesWindow.titleText'
 932                                        )
 933                                    ),
 934                                ),
 935                            ],
 936                        )
 937                        bui.textwidget(
 938                            parent=cnt2,
 939                            text=txt,
 940                            size=(0, 0),
 941                            position=(self._width * 0.5, self._height - 28),
 942                            h_align='center',
 943                            v_align='center',
 944                            color=(0.7, 1, 0.7, 0.4),
 945                            scale=0.7,
 946                            shadow=0,
 947                            flatness=1.0,
 948                            maxwidth=700,
 949                            transition_delay=0.4,
 950                        )
 951                    elif self._tab == 'icons':
 952                        txt = bui.Lstr(
 953                            resource='store.howToUseIconsText',
 954                            subs=[
 955                                (
 956                                    '${SETTINGS}',
 957                                    bui.Lstr(resource='mainMenu.settingsText'),
 958                                ),
 959                                (
 960                                    '${PLAYER_PROFILES}',
 961                                    bui.Lstr(
 962                                        resource=(
 963                                            'playerProfilesWindow.titleText'
 964                                        )
 965                                    ),
 966                                ),
 967                            ],
 968                        )
 969                        bui.textwidget(
 970                            parent=cnt2,
 971                            text=txt,
 972                            size=(0, 0),
 973                            position=(self._width * 0.5, self._height - 28),
 974                            h_align='center',
 975                            v_align='center',
 976                            color=(0.7, 1, 0.7, 0.4),
 977                            scale=0.7,
 978                            shadow=0,
 979                            flatness=1.0,
 980                            maxwidth=700,
 981                            transition_delay=0.4,
 982                        )
 983                    elif self._tab == 'maps':
 984                        assert self._width is not None
 985                        assert self._height is not None
 986                        txt = bui.Lstr(resource='store.howToUseMapsText')
 987                        bui.textwidget(
 988                            parent=cnt2,
 989                            text=txt,
 990                            size=(0, 0),
 991                            position=(self._width * 0.5, self._height - 28),
 992                            h_align='center',
 993                            v_align='center',
 994                            color=(0.7, 1, 0.7, 0.4),
 995                            scale=0.7,
 996                            shadow=0,
 997                            flatness=1.0,
 998                            maxwidth=700,
 999                            transition_delay=0.4,
1000                        )
1001
1002                    prev_row_buttons: list | None = None
1003                    this_row_buttons = []
1004
1005                    delay = 0.3
1006                    for section in self._sections:
1007                        if section['title'] != '':
1008                            bui.textwidget(
1009                                parent=cnt2,
1010                                position=(60, v - title_spacing * 0.8),
1011                                size=(0, 0),
1012                                scale=1.0,
1013                                transition_delay=delay,
1014                                color=(0.7, 0.9, 0.7, 1),
1015                                h_align='left',
1016                                v_align='center',
1017                                text=bui.Lstr(resource=section['title']),
1018                                maxwidth=self._width * 0.7,
1019                            )
1020                            v -= title_spacing
1021                        delay = max(0.100, delay - 0.100)
1022                        v -= button_border
1023                        b_width, b_height = section['button_size']
1024                        b_count = len(section['items'])
1025                        b_column_count = int(
1026                            math.floor(
1027                                (self._width - boffs_h - 20)
1028                                / (b_width + button_spacing)
1029                            )
1030                        )
1031                        col = 0
1032                        item: dict[str, Any]
1033                        assert self._store_window.button_infos is not None
1034                        for i, item_name in enumerate(section['items']):
1035                            item = self._store_window.button_infos[
1036                                item_name
1037                            ] = {}
1038                            item['call'] = bui.WeakCall(
1039                                self._store_window.buy, item_name
1040                            )
1041                            if 'x_offs' in section:
1042                                boffs_h2 = section['x_offs']
1043                            else:
1044                                boffs_h2 = 0
1045
1046                            if 'y_offs' in section:
1047                                boffs_v2 = section['y_offs']
1048                            else:
1049                                boffs_v2 = 0
1050                            b_pos = (
1051                                boffs_h
1052                                + boffs_h2
1053                                + (b_width + button_spacing) * col,
1054                                v - b_height + boffs_v2,
1055                            )
1056                            instantiate_store_item_display(
1057                                item_name,
1058                                item,
1059                                parent_widget=cnt2,
1060                                b_pos=b_pos,
1061                                boffs_h=boffs_h,
1062                                b_width=b_width,
1063                                b_height=b_height,
1064                                boffs_h2=boffs_h2,
1065                                boffs_v2=boffs_v2,
1066                                delay=delay,
1067                            )
1068                            btn = item['button']
1069                            delay = max(0.1, delay - 0.1)
1070                            this_row_buttons.append(btn)
1071
1072                            # Wire this button to the equivalent in the
1073                            # previous row.
1074                            if prev_row_buttons is not None:
1075                                if len(prev_row_buttons) > col:
1076                                    bui.widget(
1077                                        edit=btn,
1078                                        up_widget=prev_row_buttons[col],
1079                                    )
1080                                    bui.widget(
1081                                        edit=prev_row_buttons[col],
1082                                        down_widget=btn,
1083                                    )
1084
1085                                    # If we're the last button in our row,
1086                                    # wire any in the previous row past
1087                                    # our position to go to us if down is
1088                                    # pressed.
1089                                    if (
1090                                        col + 1 == b_column_count
1091                                        or i == b_count - 1
1092                                    ):
1093                                        for b_prev in prev_row_buttons[
1094                                            col + 1 :
1095                                        ]:
1096                                            bui.widget(
1097                                                edit=b_prev, down_widget=btn
1098                                            )
1099                                else:
1100                                    bui.widget(
1101                                        edit=btn, up_widget=prev_row_buttons[-1]
1102                                    )
1103                            else:
1104                                bui.widget(edit=btn, up_widget=tab_button)
1105
1106                            col += 1
1107                            if col == b_column_count or i == b_count - 1:
1108                                prev_row_buttons = this_row_buttons
1109                                this_row_buttons = []
1110                                col = 0
1111                                v -= b_height
1112                                if i < b_count - 1:
1113                                    v -= section['v_spacing']
1114
1115                        v -= button_border
1116
1117                    # Set a timer to update these buttons periodically as long
1118                    # as we're alive (so if we buy one it will grey out, etc).
1119                    self._store_window.update_buttons_timer = bui.AppTimer(
1120                        0.5,
1121                        bui.WeakCall(self._store_window.update_buttons),
1122                        repeat=True,
1123                    )
1124
1125                    # Also update them immediately.
1126                    self._store_window.update_buttons()
1127
1128            if self._current_tab in (
1129                self.TabID.EXTRAS,
1130                self.TabID.MINIGAMES,
1131                self.TabID.CHARACTERS,
1132                self.TabID.MAPS,
1133                self.TabID.ICONS,
1134            ):
1135                store = _Store(self, data, self._scroll_width)
1136                assert self._scrollwidget is not None
1137                store.instantiate(
1138                    scrollwidget=self._scrollwidget,
1139                    tab_button=self._tab_row.tabs[self._current_tab].button,
1140                )
1141            else:
1142                cnt = bui.containerwidget(
1143                    parent=self._scrollwidget,
1144                    scale=1.0,
1145                    size=(self._scroll_width, self._scroll_height * 0.95),
1146                    background=False,
1147                    claims_left_right=True,
1148                    selection_loops_to_parent=True,
1149                )
1150                self._status_textwidget = bui.textwidget(
1151                    parent=cnt,
1152                    position=(
1153                        self._scroll_width * 0.5,
1154                        self._scroll_height * 0.5,
1155                    ),
1156                    size=(0, 0),
1157                    scale=1.3,
1158                    transition_delay=0.1,
1159                    color=(1, 1, 0.3, 1.0),
1160                    h_align='center',
1161                    v_align='center',
1162                    text=bui.Lstr(resource=f'{self._r}.comingSoonText'),
1163                    maxwidth=self._scroll_width * 0.9,
1164                )
1165
1166    @override
1167    def get_main_window_state(self) -> bui.MainWindowState:
1168        # Support recreating our window for back/refresh purposes.
1169        cls = type(self)
1170        return bui.BasicMainWindowState(
1171            create_call=lambda transition, origin_widget: cls(
1172                transition=transition, origin_widget=origin_widget
1173            )
1174        )
1175
1176    @override
1177    def on_main_window_close(self) -> None:
1178        self._save_state()
1179
1180    def _save_state(self) -> None:
1181        try:
1182            sel = self._root_widget.get_selected_child()
1183            selected_tab_ids = [
1184                tab_id
1185                for tab_id, tab in self._tab_row.tabs.items()
1186                if sel == tab.button
1187            ]
1188            if sel == self._scrollwidget:
1189                sel_name = 'Scroll'
1190            elif sel == self._back_button:
1191                sel_name = 'Back'
1192            elif selected_tab_ids:
1193                assert len(selected_tab_ids) == 1
1194                sel_name = f'Tab:{selected_tab_ids[0].value}'
1195            else:
1196                raise ValueError(f'unrecognized selection \'{sel}\'')
1197            assert bui.app.classic is not None
1198            bui.app.ui_v1.window_states[type(self)] = {
1199                'sel_name': sel_name,
1200            }
1201        except Exception:
1202            logging.exception('Error saving state for %s.', self)
1203
1204    def _restore_state(self) -> None:
1205
1206        try:
1207            sel: bui.Widget | None
1208            assert bui.app.classic is not None
1209            sel_name = bui.app.ui_v1.window_states.get(type(self), {}).get(
1210                'sel_name'
1211            )
1212            assert isinstance(sel_name, (str, type(None)))
1213
1214            try:
1215                current_tab = self.TabID(bui.app.config.get('Store Tab'))
1216            except ValueError:
1217                current_tab = self.TabID.CHARACTERS
1218
1219            if self._show_tab is not None:
1220                current_tab = self._show_tab
1221            if sel_name == 'Back':
1222                sel = self._back_button
1223            elif sel_name == 'Scroll':
1224                sel = self._scrollwidget
1225            elif isinstance(sel_name, str) and sel_name.startswith('Tab:'):
1226                try:
1227                    sel_tab_id = self.TabID(sel_name.split(':')[-1])
1228                except ValueError:
1229                    sel_tab_id = self.TabID.CHARACTERS
1230                sel = self._tab_row.tabs[sel_tab_id].button
1231            else:
1232                sel = self._tab_row.tabs[current_tab].button
1233
1234            # If we were requested to show a tab, select it too.
1235            if (
1236                self._show_tab is not None
1237                and self._show_tab in self._tab_row.tabs
1238            ):
1239                sel = self._tab_row.tabs[self._show_tab].button
1240            self._set_tab(current_tab)
1241            if sel is not None:
1242                bui.containerwidget(edit=self._root_widget, selected_child=sel)
1243        except Exception:
1244            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 - (55 if uiscale is bui.UIScale.SMALL else 44),
146            ),
147            size=(0, 0),
148            color=app.ui_v1.title_color,
149            scale=1.1 if uiscale is bui.UIScale.SMALL else 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:
492    def buy(self, item: str) -> None:
493        """Attempt to purchase the provided item."""
494        from bauiv1lib.account.signin import show_sign_in_prompt
495        from bauiv1lib.confirm import ConfirmWindow
496
497        assert bui.app.classic is not None
498        store = bui.app.classic.store
499
500        plus = bui.app.plus
501        assert plus is not None
502
503        # Prevent pressing buy within a few seconds of the last press
504        # (gives the buttons time to disable themselves and whatnot).
505        curtime = bui.apptime()
506        if (
507            self._last_buy_time is not None
508            and (curtime - self._last_buy_time) < 2.0
509        ):
510            bui.getsound('error').play()
511        else:
512            if plus.get_v1_account_state() != 'signed_in':
513                show_sign_in_prompt()
514            else:
515                self._last_buy_time = curtime
516
517                # Merch is a special case - just a link.
518                if item == 'merch':
519                    url = bui.app.config.get('Merch Link')
520                    if isinstance(url, str):
521                        bui.open_url(url)
522
523                # Pro is an actual IAP, and the rest are ticket purchases.
524                elif item == 'pro':
525                    bui.getsound('click01').play()
526
527                    # Purchase either pro or pro_sale depending on whether
528                    # there is a sale going on.
529                    self._do_purchase_check(
530                        'pro'
531                        if store.get_available_sale_time('extras') is None
532                        else 'pro_sale'
533                    )
534                else:
535                    price = plus.get_v1_account_misc_read_val(
536                        'price.' + item, None
537                    )
538                    our_tickets = plus.get_v1_account_ticket_count()
539                    if price is not None and our_tickets < price:
540                        bui.getsound('error').play()
541                        bui.screenmessage(
542                            bui.Lstr(resource='notEnoughTicketsText'),
543                            color=(1, 0, 0),
544                        )
545                        # gettickets.show_get_tickets_prompt()
546                    else:
547
548                        def do_it() -> None:
549                            self._do_purchase_check(
550                                item, is_ticket_purchase=True
551                            )
552
553                        bui.getsound('swish').play()
554                        ConfirmWindow(
555                            bui.Lstr(
556                                resource='store.purchaseConfirmText',
557                                subs=[
558                                    (
559                                        '${ITEM}',
560                                        store.get_store_item_name_translated(
561                                            item
562                                        ),
563                                    )
564                                ],
565                            ),
566                            width=400,
567                            height=120,
568                            action=do_it,
569                            ok_text=bui.Lstr(
570                                resource='store.purchaseText',
571                                fallback_resource='okText',
572                            ),
573                        )

Attempt to purchase the provided item.

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

Update our buttons.

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

Return a WindowState to recreate this window, if supported.

@override
def on_main_window_close(self) -> None:
1176    @override
1177    def on_main_window_close(self) -> None:
1178        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'>