bauiv1lib.store.browser

UI for browsing the store.

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

Window for browsing the store.

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

Create a MainWindow given a root widget and transition info.

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

button_infos: dict[str, dict[str, typing.Any]] | None
update_buttons_timer: _babase.AppTimer | None
request: Any
def buy(self, item: str) -> None:
492    def buy(self, item: str) -> None:
493        """Attempt to purchase the provided item."""
494        from bauiv1lib import account
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                account.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                        print('FIXME - show not-enough-tickets info.')
542                        # gettickets.show_get_tickets_prompt()
543                    else:
544
545                        def do_it() -> None:
546                            self._do_purchase_check(
547                                item, is_ticket_purchase=True
548                            )
549
550                        bui.getsound('swish').play()
551                        ConfirmWindow(
552                            bui.Lstr(
553                                resource='store.purchaseConfirmText',
554                                subs=[
555                                    (
556                                        '${ITEM}',
557                                        store.get_store_item_name_translated(
558                                            item
559                                        ),
560                                    )
561                                ],
562                            ),
563                            width=400,
564                            height=120,
565                            action=do_it,
566                            ok_text=bui.Lstr(
567                                resource='store.purchaseText',
568                                fallback_resource='okText',
569                            ),
570                        )

Attempt to purchase the provided item.

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

Update our buttons.

@override
def get_main_window_state(self) -> bauiv1.MainWindowState:
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        )

Return a WindowState to recreate this window, if supported.

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