bauiv1lib.store.browser

UI for browsing the store.

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

Window for browsing the store.

StoreBrowserWindow( transition: str | None = 'in_right', origin_widget: _bauiv1.Widget | None = None, show_tab: StoreBrowserWindow.TabID | None = None, minimal_toolbars: bool = False)
 42    def __init__(
 43        self,
 44        transition: str | None = 'in_right',
 45        origin_widget: bui.Widget | None = None,
 46        show_tab: StoreBrowserWindow.TabID | None = None,
 47        minimal_toolbars: bool = False,
 48    ):
 49        # pylint: disable=too-many-statements
 50        # pylint: disable=too-many-locals
 51        from bauiv1lib.tabs import TabRow
 52        from bauiv1 import SpecialChar
 53
 54        app = bui.app
 55        assert app.classic is not None
 56        uiscale = app.ui_v1.uiscale
 57
 58        bui.set_analytics_screen('Store Window')
 59
 60        self.button_infos: dict[str, dict[str, Any]] | None = None
 61        self.update_buttons_timer: bui.AppTimer | None = None
 62        self._status_textwidget_update_timer = None
 63
 64        self._show_tab = show_tab
 65        self._width = (
 66            1800
 67            if uiscale is bui.UIScale.SMALL
 68            else 1000 if uiscale is bui.UIScale.MEDIUM else 1120
 69        )
 70        self._height = (
 71            1200
 72            if uiscale is bui.UIScale.SMALL
 73            else 700 if uiscale is bui.UIScale.MEDIUM else 800
 74        )
 75        self._current_tab: StoreBrowserWindow.TabID | None = None
 76        # extra_top = 30 if uiscale is bui.UIScale.SMALL else 0
 77
 78        self.request: Any = None
 79        self._r = 'store'
 80        self._last_buy_time: float | None = None
 81
 82        # Do some fancy math to fill all available screen area up to the
 83        # size of our backing container. This lets us fit to the exact
 84        # screen shape at small ui scale.
 85        screensize = bui.get_virtual_screen_size()
 86        scale = (
 87            1.5
 88            if uiscale is bui.UIScale.SMALL
 89            else 0.9 if uiscale is bui.UIScale.MEDIUM else 0.8
 90        )
 91
 92        # Calc screen size in our local container space and clamp to a
 93        # bit smaller than our container size.
 94        target_width = min(self._width - 120, screensize[0] / scale)
 95        target_height = min(self._height - 140, screensize[1] / scale)
 96
 97        # To get top/left coords, go to the center of our window and
 98        # offset by half the width/height of our target area.
 99        yoffs = 0.5 * self._height + 0.5 * target_height + 30.0
100
101        self._scroll_width = target_width
102        self._scroll_height = target_height - 59
103        self._scroll_bottom = yoffs - 87 - self._scroll_height
104
105        super().__init__(
106            root_widget=bui.containerwidget(
107                size=(self._width, self._height),
108                toolbar_visibility=(
109                    'menu_store'
110                    if (uiscale is bui.UIScale.SMALL or minimal_toolbars)
111                    else 'menu_full'
112                ),
113                scale=scale,
114            ),
115            transition=transition,
116            origin_widget=origin_widget,
117            # We're affected by screen size only at small ui-scale.
118            refresh_on_screen_size_changes=uiscale is bui.UIScale.SMALL,
119        )
120
121        self._back_button = btn = bui.buttonwidget(
122            parent=self._root_widget,
123            position=(70, yoffs - 37),
124            size=(60, 60),
125            scale=1.1,
126            autoselect=True,
127            label=bui.charstr(SpecialChar.BACK),
128            button_type='backSmall',
129            on_activate_call=self.main_window_back,
130        )
131
132        if uiscale is bui.UIScale.SMALL:
133            self._back_button.delete()
134            bui.containerwidget(
135                edit=self._root_widget, on_cancel_call=self.main_window_back
136            )
137        else:
138            bui.containerwidget(edit=self._root_widget, cancel_button=btn)
139
140        if (
141            app.classic.platform in ['mac', 'ios']
142            and app.classic.subplatform == 'appstore'
143        ):
144            bui.buttonwidget(
145                parent=self._root_widget,
146                position=(self._width * 0.5 - 70, 16),
147                size=(230, 50),
148                scale=0.65,
149                on_activate_call=bui.WeakCall(self._restore_purchases),
150                color=(0.35, 0.3, 0.4),
151                selectable=False,
152                textcolor=(0.55, 0.5, 0.6),
153                label=bui.Lstr(
154                    resource='getTicketsWindow.restorePurchasesText'
155                ),
156            )
157
158        bui.textwidget(
159            parent=self._root_widget,
160            position=(
161                (
162                    self._width * 0.5
163                    + (
164                        (self._scroll_width * -0.5 + 90.0)
165                        if uiscale is bui.UIScale.SMALL
166                        else 0.0
167                    )
168                ),
169                yoffs - (62 if uiscale is bui.UIScale.SMALL else -3.0),
170            ),
171            size=(0, 0),
172            color=app.ui_v1.title_color,
173            scale=1.1 if uiscale is bui.UIScale.SMALL else 1.3,
174            h_align='left' if uiscale is bui.UIScale.SMALL else 'center',
175            v_align='center',
176            text=bui.Lstr(resource='storeText'),
177            maxwidth=100 if uiscale is bui.UIScale.SMALL else 290,
178        )
179
180        tabs_def = [
181            # (self.TabID.EXTRAS, bui.Lstr(resource=f'{self._r}.extrasText')),
182            (self.TabID.MAPS, bui.Lstr(resource=f'{self._r}.mapsText')),
183            (
184                self.TabID.MINIGAMES,
185                bui.Lstr(resource=f'{self._r}.miniGamesText'),
186            ),
187            (
188                self.TabID.CHARACTERS,
189                bui.Lstr(resource=f'{self._r}.charactersText'),
190            ),
191            (self.TabID.ICONS, bui.Lstr(resource=f'{self._r}.iconsText')),
192        ]
193
194        tab_inset = 200 if uiscale is bui.UIScale.SMALL else 100
195        self._tab_row = TabRow(
196            self._root_widget,
197            tabs_def,
198            size=(self._scroll_width - 2.0 * tab_inset, 50),
199            pos=(
200                self._width * 0.5 - self._scroll_width * 0.5 + tab_inset,
201                self._scroll_bottom + self._scroll_height - 4.0,
202            ),
203            on_select_call=self._set_tab,
204        )
205
206        self._purchasable_count_widgets: dict[
207            StoreBrowserWindow.TabID, dict[str, Any]
208        ] = {}
209
210        # Create our purchasable-items tags and have them update over time.
211        for tab_id, tab in self._tab_row.tabs.items():
212            pos = tab.position
213            size = tab.size
214            button = tab.button
215            rad = 10
216            center = (pos[0] + 0.1 * size[0], pos[1] + 0.9 * size[1])
217            img = bui.imagewidget(
218                parent=self._root_widget,
219                position=(center[0] - rad * 1.1, center[1] - rad * 1.2),
220                size=(rad * 2.4, rad * 2.4),
221                texture=bui.gettexture('circleShadow'),
222                color=(1, 0, 0),
223            )
224            txt = bui.textwidget(
225                parent=self._root_widget,
226                position=center,
227                size=(0, 0),
228                h_align='center',
229                v_align='center',
230                maxwidth=1.4 * rad,
231                scale=0.6,
232                shadow=1.0,
233                flatness=1.0,
234            )
235            rad = 20
236            sale_img = bui.imagewidget(
237                parent=self._root_widget,
238                position=(center[0] - rad, center[1] - rad),
239                size=(rad * 2, rad * 2),
240                draw_controller=button,
241                texture=bui.gettexture('circleZigZag'),
242                color=(0.5, 0, 1.0),
243            )
244            sale_title_text = bui.textwidget(
245                parent=self._root_widget,
246                position=(center[0], center[1] + 0.24 * rad),
247                size=(0, 0),
248                h_align='center',
249                v_align='center',
250                draw_controller=button,
251                maxwidth=1.4 * rad,
252                scale=0.6,
253                shadow=0.0,
254                flatness=1.0,
255                color=(0, 1, 0),
256            )
257            sale_time_text = bui.textwidget(
258                parent=self._root_widget,
259                position=(center[0], center[1] - 0.29 * rad),
260                size=(0, 0),
261                h_align='center',
262                v_align='center',
263                draw_controller=button,
264                maxwidth=1.4 * rad,
265                scale=0.4,
266                shadow=0.0,
267                flatness=1.0,
268                color=(0, 1, 0),
269            )
270            self._purchasable_count_widgets[tab_id] = {
271                'img': img,
272                'text': txt,
273                'sale_img': sale_img,
274                'sale_title_text': sale_title_text,
275                'sale_time_text': sale_time_text,
276            }
277        self._tab_update_timer = bui.AppTimer(
278            1.0, bui.WeakCall(self._update_tabs), repeat=True
279        )
280        self._update_tabs()
281
282        if uiscale is bui.UIScale.SMALL:
283            first_tab_button = self._tab_row.tabs[tabs_def[0][0]].button
284            last_tab_button = self._tab_row.tabs[tabs_def[-1][0]].button
285            bui.widget(
286                edit=first_tab_button,
287                left_widget=bui.get_special_widget('back_button'),
288                up_widget=bui.get_special_widget('back_button'),
289            )
290            bui.widget(
291                edit=last_tab_button,
292                up_widget=bui.get_special_widget('tickets_meter'),
293                right_widget=bui.get_special_widget('tickets_meter'),
294            )
295
296        # self._scroll_width = self._width - scroll_buffer_h
297        # self._scroll_height = self._height - 180
298
299        self._scrollwidget: bui.Widget | None = None
300        self._status_textwidget: bui.Widget | None = None
301        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:
511    def buy(self, item: str) -> None:
512        """Attempt to purchase the provided item."""
513        from bauiv1lib.account.signin import show_sign_in_prompt
514        from bauiv1lib.confirm import ConfirmWindow
515
516        assert bui.app.classic is not None
517        store = bui.app.classic.store
518
519        plus = bui.app.plus
520        assert plus is not None
521
522        # Prevent pressing buy within a few seconds of the last press
523        # (gives the buttons time to disable themselves and whatnot).
524        curtime = bui.apptime()
525        if (
526            self._last_buy_time is not None
527            and (curtime - self._last_buy_time) < 2.0
528        ):
529            bui.getsound('error').play()
530        else:
531            if plus.get_v1_account_state() != 'signed_in':
532                show_sign_in_prompt()
533            else:
534                self._last_buy_time = curtime
535
536                # Merch is a special case - just a link.
537                if item == 'merch':
538                    url = bui.app.config.get('Merch Link')
539                    if isinstance(url, str):
540                        bui.open_url(url)
541
542                # Pro is an actual IAP, and the rest are ticket purchases.
543                elif item == 'pro':
544                    bui.getsound('click01').play()
545
546                    # Purchase either pro or pro_sale depending on whether
547                    # there is a sale going on.
548                    self._do_purchase_check(
549                        'pro'
550                        if store.get_available_sale_time('extras') is None
551                        else 'pro_sale'
552                    )
553                else:
554                    price = plus.get_v1_account_misc_read_val(
555                        'price.' + item, None
556                    )
557                    our_tickets = plus.get_v1_account_ticket_count()
558                    if price is not None and our_tickets < price:
559                        bui.getsound('error').play()
560                        bui.screenmessage(
561                            bui.Lstr(resource='notEnoughTicketsText'),
562                            color=(1, 0, 0),
563                        )
564                        # gettickets.show_get_tickets_prompt()
565                    else:
566
567                        def do_it() -> None:
568                            self._do_purchase_check(
569                                item, is_ticket_purchase=True
570                            )
571
572                        bui.getsound('swish').play()
573                        ConfirmWindow(
574                            bui.Lstr(
575                                resource='store.purchaseConfirmText',
576                                subs=[
577                                    (
578                                        '${ITEM}',
579                                        store.get_store_item_name_translated(
580                                            item
581                                        ),
582                                    )
583                                ],
584                            ),
585                            width=400,
586                            height=120,
587                            action=do_it,
588                            ok_text=bui.Lstr(
589                                resource='store.purchaseText',
590                                fallback_resource='okText',
591                            ),
592                        )

Attempt to purchase the provided item.

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

Update our buttons.

@override
def get_main_window_state(self) -> bauiv1.MainWindowState:
1186    @override
1187    def get_main_window_state(self) -> bui.MainWindowState:
1188        # Support recreating our window for back/refresh purposes.
1189        cls = type(self)
1190        return bui.BasicMainWindowState(
1191            create_call=lambda transition, origin_widget: cls(
1192                transition=transition, origin_widget=origin_widget
1193            )
1194        )

Return a WindowState to recreate this window, if supported.

@override
def on_main_window_close(self) -> None:
1196    @override
1197    def on_main_window_close(self) -> None:
1198        self._save_state()

Called before transitioning out a main window.

A good opportunity to save window state/etc.

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

Our available tab types.

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