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

Window for browsing the store.

StoreBrowserWindow( transition: str | None = 'in_right', modal: bool = False, show_tab: StoreBrowserWindow.TabID | None = None, on_close_call: Optional[Callable[[], Any]] = None, back_location: str | None = None, origin_widget: _bauiv1.Widget | None = None)
 42    def __init__(
 43        self,
 44        transition: str | None = 'in_right',
 45        modal: bool = False,
 46        show_tab: StoreBrowserWindow.TabID | None = None,
 47        on_close_call: Callable[[], Any] | None = None,
 48        back_location: str | None = None,
 49        origin_widget: bui.Widget | None = None,
 50    ):
 51        # pylint: disable=too-many-statements
 52        # pylint: disable=too-many-locals
 53        from bauiv1lib.tabs import TabRow
 54        from bauiv1 import SpecialChar
 55
 56        app = bui.app
 57        assert app.classic is not None
 58        uiscale = app.ui_v1.uiscale
 59
 60        bui.set_analytics_screen('Store Window')
 61
 62        # Need to store this ourself for modal mode.
 63        if origin_widget is not None:
 64            self._transition_out = 'out_scale'
 65        else:
 66            self._transition_out = 'out_right'
 67
 68        self.button_infos: dict[str, dict[str, Any]] | None = None
 69        self.update_buttons_timer: bui.AppTimer | None = None
 70        self._status_textwidget_update_timer = None
 71
 72        self._back_location = back_location
 73        self._on_close_call = on_close_call
 74        self._show_tab = show_tab
 75        self._modal = modal
 76        self._width = 1670 if uiscale is bui.UIScale.SMALL else 1040
 77        self._x_inset = x_inset = 310 if uiscale is bui.UIScale.SMALL else 0
 78        self._height = (
 79            538
 80            if uiscale is bui.UIScale.SMALL
 81            else 645 if uiscale is bui.UIScale.MEDIUM else 800
 82        )
 83        self._current_tab: StoreBrowserWindow.TabID | None = None
 84        extra_top = 30 if uiscale is bui.UIScale.SMALL else 0
 85
 86        self.request: Any = None
 87        self._r = 'store'
 88        self._last_buy_time: float | None = None
 89
 90        super().__init__(
 91            root_widget=bui.containerwidget(
 92                size=(self._width, self._height + extra_top),
 93                toolbar_visibility=(
 94                    'menu_store'
 95                    if uiscale is bui.UIScale.SMALL
 96                    else 'menu_full'
 97                ),
 98                scale=(
 99                    1.3
100                    if uiscale is bui.UIScale.SMALL
101                    else 0.9 if uiscale is bui.UIScale.MEDIUM else 0.8
102                ),
103                stack_offset=(
104                    (0, 10)
105                    if uiscale is bui.UIScale.SMALL
106                    else (0, 0) if uiscale is bui.UIScale.MEDIUM else (0, 0)
107                ),
108            ),
109            transition=transition,
110            origin_widget=origin_widget,
111        )
112
113        self._back_button = btn = bui.buttonwidget(
114            parent=self._root_widget,
115            position=(70 + x_inset, self._height - 74),
116            size=(140, 60),
117            scale=1.1,
118            autoselect=True,
119            label=bui.Lstr(resource='doneText' if self._modal else 'backText'),
120            button_type=None if self._modal else 'back',
121            on_activate_call=self._back,
122        )
123
124        if uiscale is bui.UIScale.SMALL:
125            self._back_button.delete()
126            bui.containerwidget(
127                edit=self._root_widget, on_cancel_call=self._back
128            )
129            # backbutton = bui.get_special_widget('back_button')
130            backbuttonspecial = True
131        else:
132            bui.containerwidget(edit=self._root_widget, cancel_button=btn)
133            # backbutton = self._back_button
134            backbuttonspecial = False
135
136        # self._ticket_count_text: bui.Widget | None = None
137        # self._get_tickets_button: bui.Widget | None = None
138
139        # if bool(False):
140        #     if app.classic.allow_ticket_purchases:
141        #         self._get_tickets_button = bui.buttonwidget(
142        #             parent=self._root_widget,
143        #             size=(210, 65),
144        #             on_activate_call=self._on_get_more_tickets_press,
145        #             autoselect=True,
146        #             scale=0.9,
147        #             text_scale=1.4,
148        #             left_widget=backbutton,
149        #             color=(0.7, 0.5, 0.85),
150        #             textcolor=(0.2, 1.0, 0.2),
151        #             label=bui.Lstr(resource='getTicketsWindow.titleText'),
152        #         )
153        #     else:
154        #         self._ticket_count_text = bui.textwidget(
155        #             parent=self._root_widget,
156        #             size=(210, 64),
157        #             color=(0.2, 1.0, 0.2),
158        #             h_align='center',
159        #             v_align='center',
160        #         )
161
162        # Move this dynamically to keep it out of the way of the party icon.
163        # self._update_get_tickets_button_pos()
164        # self._get_ticket_pos_update_timer = bui.AppTimer(
165        #     1.0,
166        #     bui.WeakCall(self._update_get_tickets_button_pos),
167        #     repeat=True,
168        # )
169        # if self._get_tickets_button and not backbuttonspecial:
170        #     bui.widget(
171        #         edit=self._back_button, right_widget=self._get_tickets_button
172        #     )
173        # self._ticket_text_update_timer = bui.AppTimer(
174        #     1.0, bui.WeakCall(self._update_tickets_text), repeat=True
175        # )
176        # self._update_tickets_text()
177
178        if (
179            app.classic.platform in ['mac', 'ios']
180            and app.classic.subplatform == 'appstore'
181        ):
182            bui.buttonwidget(
183                parent=self._root_widget,
184                position=(self._width * 0.5 - 70, 16),
185                size=(230, 50),
186                scale=0.65,
187                on_activate_call=bui.WeakCall(self._restore_purchases),
188                color=(0.35, 0.3, 0.4),
189                selectable=False,
190                textcolor=(0.55, 0.5, 0.6),
191                label=bui.Lstr(
192                    resource='getTicketsWindow.restorePurchasesText'
193                ),
194            )
195
196        bui.textwidget(
197            parent=self._root_widget,
198            position=(
199                self._width * 0.5,
200                self._height - (53 if uiscale is bui.UIScale.SMALL else 44),
201            ),
202            size=(0, 0),
203            color=app.ui_v1.title_color,
204            scale=1.5,
205            h_align='center',
206            v_align='center',
207            text=bui.Lstr(resource='storeText'),
208            maxwidth=290,
209        )
210
211        if not self._modal and not backbuttonspecial:
212            bui.buttonwidget(
213                edit=self._back_button,
214                button_type='backSmall',
215                size=(60, 60),
216                label=bui.charstr(SpecialChar.BACK),
217            )
218
219        scroll_buffer_h = 130 + 2 * x_inset
220        tab_buffer_h = 250 + 2 * x_inset
221
222        tabs_def = [
223            (self.TabID.EXTRAS, bui.Lstr(resource=f'{self._r}.extrasText')),
224            (self.TabID.MAPS, bui.Lstr(resource=f'{self._r}.mapsText')),
225            (
226                self.TabID.MINIGAMES,
227                bui.Lstr(resource=f'{self._r}.miniGamesText'),
228            ),
229            (
230                self.TabID.CHARACTERS,
231                bui.Lstr(resource=f'{self._r}.charactersText'),
232            ),
233            (self.TabID.ICONS, bui.Lstr(resource=f'{self._r}.iconsText')),
234        ]
235
236        self._tab_row = TabRow(
237            self._root_widget,
238            tabs_def,
239            pos=(tab_buffer_h * 0.5, self._height - 130),
240            size=(self._width - tab_buffer_h, 50),
241            on_select_call=self._set_tab,
242        )
243
244        self._purchasable_count_widgets: dict[
245            StoreBrowserWindow.TabID, dict[str, Any]
246        ] = {}
247
248        # Create our purchasable-items tags and have them update over time.
249        for tab_id, tab in self._tab_row.tabs.items():
250            pos = tab.position
251            size = tab.size
252            button = tab.button
253            rad = 10
254            center = (pos[0] + 0.1 * size[0], pos[1] + 0.9 * size[1])
255            img = bui.imagewidget(
256                parent=self._root_widget,
257                position=(center[0] - rad * 1.04, center[1] - rad * 1.15),
258                size=(rad * 2.2, rad * 2.2),
259                texture=bui.gettexture('circleShadow'),
260                color=(1, 0, 0),
261            )
262            txt = bui.textwidget(
263                parent=self._root_widget,
264                position=center,
265                size=(0, 0),
266                h_align='center',
267                v_align='center',
268                maxwidth=1.4 * rad,
269                scale=0.6,
270                shadow=1.0,
271                flatness=1.0,
272            )
273            rad = 20
274            sale_img = bui.imagewidget(
275                parent=self._root_widget,
276                position=(center[0] - rad, center[1] - rad),
277                size=(rad * 2, rad * 2),
278                draw_controller=button,
279                texture=bui.gettexture('circleZigZag'),
280                color=(0.5, 0, 1.0),
281            )
282            sale_title_text = bui.textwidget(
283                parent=self._root_widget,
284                position=(center[0], center[1] + 0.24 * rad),
285                size=(0, 0),
286                h_align='center',
287                v_align='center',
288                draw_controller=button,
289                maxwidth=1.4 * rad,
290                scale=0.6,
291                shadow=0.0,
292                flatness=1.0,
293                color=(0, 1, 0),
294            )
295            sale_time_text = bui.textwidget(
296                parent=self._root_widget,
297                position=(center[0], center[1] - 0.29 * rad),
298                size=(0, 0),
299                h_align='center',
300                v_align='center',
301                draw_controller=button,
302                maxwidth=1.4 * rad,
303                scale=0.4,
304                shadow=0.0,
305                flatness=1.0,
306                color=(0, 1, 0),
307            )
308            self._purchasable_count_widgets[tab_id] = {
309                'img': img,
310                'text': txt,
311                'sale_img': sale_img,
312                'sale_title_text': sale_title_text,
313                'sale_time_text': sale_time_text,
314            }
315        self._tab_update_timer = bui.AppTimer(
316            1.0, bui.WeakCall(self._update_tabs), repeat=True
317        )
318        self._update_tabs()
319
320        # if self._get_tickets_button:
321        #     last_tab_button = self._tab_row.tabs[tabs_def[-1][0]].button
322        #     bui.widget(
323        #         edit=self._get_tickets_button, down_widget=last_tab_button
324        #     )
325        #     bui.widget(
326        #         edit=last_tab_button,
327        #         up_widget=self._get_tickets_button,
328        #         right_widget=self._get_tickets_button,
329        #     )
330
331        if uiscale is bui.UIScale.SMALL:
332            first_tab_button = self._tab_row.tabs[tabs_def[0][0]].button
333            last_tab_button = self._tab_row.tabs[tabs_def[-1][0]].button
334            bui.widget(
335                edit=first_tab_button,
336                left_widget=bui.get_special_widget('back_button'),
337            )
338            bui.widget(
339                edit=last_tab_button,
340                right_widget=bui.get_special_widget('squad_button'),
341            )
342
343        self._scroll_width = self._width - scroll_buffer_h
344        self._scroll_height = self._height - 180
345
346        self._scrollwidget: bui.Widget | None = None
347        self._status_textwidget: bui.Widget | None = None
348        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:
600    def buy(self, item: str) -> None:
601        """Attempt to purchase the provided item."""
602        from bauiv1lib import account
603        from bauiv1lib.confirm import ConfirmWindow
604
605        # from bauiv1lib import gettickets
606
607        assert bui.app.classic is not None
608        store = bui.app.classic.store
609
610        plus = bui.app.plus
611        assert plus is not None
612
613        # Prevent pressing buy within a few seconds of the last press
614        # (gives the buttons time to disable themselves and whatnot).
615        curtime = bui.apptime()
616        if (
617            self._last_buy_time is not None
618            and (curtime - self._last_buy_time) < 2.0
619        ):
620            bui.getsound('error').play()
621        else:
622            if plus.get_v1_account_state() != 'signed_in':
623                account.show_sign_in_prompt()
624            else:
625                self._last_buy_time = curtime
626
627                # Merch is a special case - just a link.
628                if item == 'merch':
629                    url = bui.app.config.get('Merch Link')
630                    if isinstance(url, str):
631                        bui.open_url(url)
632
633                # Pro is an actual IAP, and the rest are ticket purchases.
634                elif item == 'pro':
635                    bui.getsound('click01').play()
636
637                    # Purchase either pro or pro_sale depending on whether
638                    # there is a sale going on.
639                    self._do_purchase_check(
640                        'pro'
641                        if store.get_available_sale_time('extras') is None
642                        else 'pro_sale'
643                    )
644                else:
645                    price = plus.get_v1_account_misc_read_val(
646                        'price.' + item, None
647                    )
648                    our_tickets = plus.get_v1_account_ticket_count()
649                    if price is not None and our_tickets < price:
650                        bui.getsound('error').play()
651                        print('FIXME - show not-enough-tickets info.')
652                        # gettickets.show_get_tickets_prompt()
653                    else:
654
655                        def do_it() -> None:
656                            self._do_purchase_check(
657                                item, is_ticket_purchase=True
658                            )
659
660                        bui.getsound('swish').play()
661                        ConfirmWindow(
662                            bui.Lstr(
663                                resource='store.purchaseConfirmText',
664                                subs=[
665                                    (
666                                        '${ITEM}',
667                                        store.get_store_item_name_translated(
668                                            item
669                                        ),
670                                    )
671                                ],
672                            ),
673                            width=400,
674                            height=120,
675                            action=do_it,
676                            ok_text=bui.Lstr(
677                                resource='store.purchaseText',
678                                fallback_resource='okText',
679                            ),
680                        )

Attempt to purchase the provided item.

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

Update our buttons.

@override
def get_main_window_state(self) -> bauiv1.MainWindowState:
1274    @override
1275    def get_main_window_state(self) -> bui.MainWindowState:
1276        # Support recreating our window for back/refresh purposes.
1277        cls = type(self)
1278        return bui.BasicMainWindowState(
1279            create_call=lambda transition, origin_widget: cls(
1280                transition=transition, origin_widget=origin_widget
1281            )
1282        )

Return a WindowState to recreate this window, if supported.

@override
def on_main_window_close(self) -> None:
1284    @override
1285    def on_main_window_close(self) -> None:
1286        self._save_state()

Called before transitioning out a main window.

A good opportunity to save window state/etc.

Inherited Members
bauiv1._uitypes.MainWindow
main_window_back_state
main_window_close
can_change_main_window
main_window_back
main_window_replace
bauiv1._uitypes.Window
get_root_widget
class StoreBrowserWindow.TabID(enum.Enum):
33    class TabID(Enum):
34        """Our available tab types."""
35
36        EXTRAS = 'extras'
37        MAPS = 'maps'
38        MINIGAMES = 'minigames'
39        CHARACTERS = 'characters'
40        ICONS = 'icons'

Our available tab types.

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