bauiv1lib.account.settings

Provides UI for account functionality.

   1# Released under the MIT License. See LICENSE for details.
   2#
   3"""Provides UI for account functionality."""
   4# pylint: disable=too-many-lines
   5
   6from __future__ import annotations
   7
   8import time
   9import logging
  10from typing import override
  11
  12from bacommon.cloud import WebLocation
  13from bacommon.login import LoginType
  14import bacommon.cloud
  15import bauiv1 as bui
  16
  17
  18# These days we're directing people to the web based account settings
  19# for V2 account linking and trying to get them to disconnect remaining
  20# V1 links, but leaving this escape hatch here in case needed.
  21FORCE_ENABLE_V1_LINKING = False
  22
  23
  24class AccountSettingsWindow(bui.MainWindow):
  25    """Window for account related functionality."""
  26
  27    def __init__(
  28        self,
  29        transition: str | None = 'in_right',
  30        modal: bool = False,
  31        origin_widget: bui.Widget | None = None,
  32        close_once_signed_in: bool = False,
  33    ):
  34        # pylint: disable=too-many-statements
  35
  36        plus = bui.app.plus
  37        assert plus is not None
  38
  39        self._sign_in_v2_proxy_button: bui.Widget | None = None
  40        self._sign_in_device_button: bui.Widget | None = None
  41
  42        self._show_legacy_unlink_button = False
  43
  44        self._signing_in_adapter: bui.LoginAdapter | None = None
  45        self._close_once_signed_in = close_once_signed_in
  46        bui.set_analytics_screen('Account Window')
  47
  48        self._explicitly_signed_out_of_gpgs = False
  49
  50        self._r = 'accountSettingsWindow'
  51        self._modal = modal
  52        self._needs_refresh = False
  53        self._v1_signed_in = plus.get_v1_account_state() == 'signed_in'
  54        self._v1_account_state_num = plus.get_v1_account_state_num()
  55        self._check_sign_in_timer = bui.AppTimer(
  56            1.0, bui.WeakCall(self._update), repeat=True
  57        )
  58
  59        self._can_reset_achievements = False
  60
  61        app = bui.app
  62        assert app.classic is not None
  63        uiscale = app.ui_v1.uiscale
  64
  65        self._width = 850 if uiscale is bui.UIScale.SMALL else 660
  66        x_offs = 70 if uiscale is bui.UIScale.SMALL else 0
  67        self._height = (
  68            380
  69            if uiscale is bui.UIScale.SMALL
  70            else 430 if uiscale is bui.UIScale.MEDIUM else 490
  71        )
  72
  73        self._sign_in_button = None
  74        self._sign_in_text = None
  75
  76        self._scroll_width = self._width - (100 + x_offs * 2)
  77        self._scroll_height = self._height - 120
  78        self._sub_width = self._scroll_width - 20
  79
  80        # Determine which sign-in/sign-out buttons we should show.
  81        self._show_sign_in_buttons: list[str] = []
  82
  83        if LoginType.GPGS in plus.accounts.login_adapters:
  84            self._show_sign_in_buttons.append('Google Play')
  85
  86        if LoginType.GAME_CENTER in plus.accounts.login_adapters:
  87            self._show_sign_in_buttons.append('Game Center')
  88
  89        # Always want to show our web-based v2 login option.
  90        self._show_sign_in_buttons.append('V2Proxy')
  91
  92        # Legacy v1 device accounts available only if the user
  93        # has explicitly enabled deprecated login types.
  94        if bui.app.config.resolve('Show Deprecated Login Types'):
  95            self._show_sign_in_buttons.append('Device')
  96
  97        top_extra = 15 if uiscale is bui.UIScale.SMALL else 0
  98        super().__init__(
  99            root_widget=bui.containerwidget(
 100                size=(self._width, self._height + top_extra),
 101                toolbar_visibility=(
 102                    'menu_minimal'
 103                    if uiscale is bui.UIScale.SMALL
 104                    else 'menu_full'
 105                ),
 106                scale=(
 107                    2.07
 108                    if uiscale is bui.UIScale.SMALL
 109                    else 1.4 if uiscale is bui.UIScale.MEDIUM else 1.0
 110                ),
 111                stack_offset=(
 112                    (0, 8) if uiscale is bui.UIScale.SMALL else (0, 0)
 113                ),
 114            ),
 115            transition=transition,
 116            origin_widget=origin_widget,
 117        )
 118        if uiscale is bui.UIScale.SMALL:
 119            self._back_button = None
 120            bui.containerwidget(
 121                edit=self._root_widget, on_cancel_call=self.main_window_back
 122            )
 123        else:
 124            self._back_button = btn = bui.buttonwidget(
 125                parent=self._root_widget,
 126                position=(51 + x_offs, self._height - 62),
 127                size=(120, 60),
 128                scale=0.8,
 129                text_scale=1.2,
 130                autoselect=True,
 131                label=bui.Lstr(
 132                    resource='cancelText' if self._modal else 'backText'
 133                ),
 134                button_type='regular' if self._modal else 'back',
 135                on_activate_call=(
 136                    self._modal_close if self._modal else self.main_window_back
 137                ),
 138            )
 139            bui.containerwidget(edit=self._root_widget, cancel_button=btn)
 140            if not self._modal:
 141                bui.buttonwidget(
 142                    edit=btn,
 143                    button_type='backSmall',
 144                    size=(60, 56),
 145                    label=bui.charstr(bui.SpecialChar.BACK),
 146                )
 147
 148        titleyoffs = -12 if uiscale is bui.UIScale.SMALL else 0
 149        titlescale = 0.6 if uiscale is bui.UIScale.SMALL else 1.0
 150        bui.textwidget(
 151            parent=self._root_widget,
 152            position=(self._width * 0.5, self._height - 41 + titleyoffs),
 153            size=(0, 0),
 154            text=bui.Lstr(resource=f'{self._r}.titleText'),
 155            color=app.ui_v1.title_color,
 156            scale=titlescale,
 157            maxwidth=self._width - 340,
 158            h_align='center',
 159            v_align='center',
 160        )
 161
 162        self._scrollwidget = bui.scrollwidget(
 163            parent=self._root_widget,
 164            highlight=False,
 165            position=(
 166                (self._width - self._scroll_width) * 0.5,
 167                self._height - 65 - self._scroll_height,
 168            ),
 169            size=(self._scroll_width, self._scroll_height),
 170            claims_left_right=True,
 171            claims_tab=True,
 172            selection_loops_to_parent=True,
 173        )
 174        self._subcontainer: bui.Widget | None = None
 175        self._refresh()
 176        self._restore_state()
 177
 178    def _modal_close(self) -> None:
 179        assert self._modal
 180
 181        # no-op if our underlying widget is dead or on its way out.
 182        if not self._root_widget or self._root_widget.transitioning_out:
 183            return
 184
 185        bui.containerwidget(
 186            edit=self._root_widget,
 187            transition=('out_right'),
 188        )
 189
 190    @override
 191    def get_main_window_state(self) -> bui.MainWindowState:
 192        # Support recreating our window for back/refresh purposes.
 193        cls = type(self)
 194        return bui.BasicMainWindowState(
 195            create_call=lambda transition, origin_widget: cls(
 196                transition=transition, origin_widget=origin_widget
 197            )
 198        )
 199
 200    @override
 201    def on_main_window_close(self) -> None:
 202        self._save_state()
 203
 204    def _update(self) -> None:
 205        plus = bui.app.plus
 206        assert plus is not None
 207
 208        # If they want us to close once we're signed in, do so.
 209        if self._close_once_signed_in and self._v1_signed_in:
 210            self.main_window_back()
 211            return
 212
 213        # Hmm should update this to use get_account_state_num.
 214        # Theoretically if we switch from one signed-in account to another
 215        # in the background this would break.
 216        v1_account_state_num = plus.get_v1_account_state_num()
 217        v1_account_state = plus.get_v1_account_state()
 218        show_legacy_unlink_button = self._should_show_legacy_unlink_button()
 219
 220        if (
 221            v1_account_state_num != self._v1_account_state_num
 222            or show_legacy_unlink_button != self._show_legacy_unlink_button
 223            or self._needs_refresh
 224        ):
 225            self._v1_account_state_num = v1_account_state_num
 226            self._v1_signed_in = v1_account_state == 'signed_in'
 227            self._show_legacy_unlink_button = show_legacy_unlink_button
 228            self._refresh()
 229
 230        # Go ahead and refresh some individual things
 231        # that may change under us.
 232        self._update_linked_accounts_text()
 233        self._update_unlink_accounts_button()
 234        self._refresh_campaign_progress_text()
 235        self._refresh_achievements()
 236        self._refresh_tickets_text()
 237        self._refresh_account_name_text()
 238
 239    def _refresh(self) -> None:
 240        # pylint: disable=too-many-statements
 241        # pylint: disable=too-many-branches
 242        # pylint: disable=too-many-locals
 243        # pylint: disable=cyclic-import
 244
 245        plus = bui.app.plus
 246        assert plus is not None
 247
 248        via_lines: list[str] = []
 249
 250        primary_v2_account = plus.accounts.primary
 251
 252        v1_state = plus.get_v1_account_state()
 253        v1_account_type = (
 254            plus.get_v1_account_type() if v1_state == 'signed_in' else 'unknown'
 255        )
 256
 257        # We expose GPGS-specific functionality only if it is 'active'
 258        # (meaning the current GPGS player matches one of our account's
 259        # logins).
 260        adapter = plus.accounts.login_adapters.get(LoginType.GPGS)
 261        gpgs_active = adapter is not None and adapter.is_back_end_active()
 262
 263        # Ditto for Game Center.
 264        adapter = plus.accounts.login_adapters.get(LoginType.GAME_CENTER)
 265        game_center_active = (
 266            adapter is not None and adapter.is_back_end_active()
 267        )
 268
 269        show_signed_in_as = self._v1_signed_in
 270        signed_in_as_space = 95.0
 271
 272        # To reduce confusion about the whole V2 account situation for
 273        # people used to seeing their Google Play Games or Game Center
 274        # account name and icon and whatnot, let's show those underneath
 275        # the V2 tag to help communicate that they are in fact logged in
 276        # through that account.
 277        via_space = 25.0
 278        if show_signed_in_as and bui.app.plus is not None:
 279            accounts = bui.app.plus.accounts
 280            if accounts.primary is not None:
 281                # For these login types, we show 'via' IF there is a
 282                # login of that type attached to our account AND it is
 283                # currently active (We don't want to show 'via Game
 284                # Center' if we're signed out of Game Center or
 285                # currently running on Steam, even if there is a Game
 286                # Center login attached to our account).
 287                for ltype, lchar in [
 288                    (LoginType.GPGS, bui.SpecialChar.GOOGLE_PLAY_GAMES_LOGO),
 289                    (LoginType.GAME_CENTER, bui.SpecialChar.GAME_CENTER_LOGO),
 290                ]:
 291                    linfo = accounts.primary.logins.get(ltype)
 292                    ladapter = accounts.login_adapters.get(ltype)
 293                    if (
 294                        linfo is not None
 295                        and ladapter is not None
 296                        and ladapter.is_back_end_active()
 297                    ):
 298                        via_lines.append(f'{bui.charstr(lchar)}{linfo.name}')
 299
 300                # TEMP TESTING
 301                if bool(False):
 302                    icontxt = bui.charstr(bui.SpecialChar.GAME_CENTER_LOGO)
 303                    via_lines.append(f'{icontxt}FloofDibble')
 304                    icontxt = bui.charstr(
 305                        bui.SpecialChar.GOOGLE_PLAY_GAMES_LOGO
 306                    )
 307                    via_lines.append(f'{icontxt}StinkBobble')
 308
 309        show_sign_in_benefits = not self._v1_signed_in
 310        sign_in_benefits_space = 80.0
 311
 312        show_signing_in_text = (
 313            v1_state == 'signing_in' or self._signing_in_adapter is not None
 314        )
 315        signing_in_text_space = 80.0
 316
 317        show_google_play_sign_in_button = (
 318            v1_state == 'signed_out'
 319            and self._signing_in_adapter is None
 320            and 'Google Play' in self._show_sign_in_buttons
 321        )
 322        show_game_center_sign_in_button = (
 323            v1_state == 'signed_out'
 324            and self._signing_in_adapter is None
 325            and 'Game Center' in self._show_sign_in_buttons
 326        )
 327        show_v2_proxy_sign_in_button = (
 328            v1_state == 'signed_out'
 329            and self._signing_in_adapter is None
 330            and 'V2Proxy' in self._show_sign_in_buttons
 331        )
 332        show_device_sign_in_button = (
 333            v1_state == 'signed_out'
 334            and self._signing_in_adapter is None
 335            and 'Device' in self._show_sign_in_buttons
 336        )
 337        sign_in_button_space = 70.0
 338        deprecated_space = 60
 339
 340        # Game Center currently has a single UI for everything.
 341        show_game_service_button = game_center_active
 342        game_service_button_space = 60.0
 343
 344        # Phasing this out (for V2 accounts at least).
 345        show_linked_accounts_text = (
 346            self._v1_signed_in and v1_account_type != 'V2'
 347        )
 348        linked_accounts_text_space = 60.0
 349
 350        # Update: No longer showing this since its visible on main
 351        # toolbar.
 352        show_achievements_text = False
 353        achievements_text_space = 27.0
 354
 355        show_leaderboards_button = self._v1_signed_in and gpgs_active
 356        leaderboards_button_space = 60.0
 357
 358        # Update: No longer showing this; trying to get progress type
 359        # stuff out of the account panel.
 360        # show_campaign_progress = self._v1_signed_in
 361        show_campaign_progress = False
 362        campaign_progress_space = 27.0
 363
 364        # show_tickets = self._v1_signed_in
 365        show_tickets = False
 366        tickets_space = 27.0
 367
 368        show_manage_account_button = primary_v2_account is not None
 369        manage_account_button_space = 70.0
 370
 371        show_delete_account_button = primary_v2_account is not None
 372        delete_account_button_space = 70.0
 373
 374        show_link_accounts_button = self._v1_signed_in and (
 375            primary_v2_account is None or FORCE_ENABLE_V1_LINKING
 376        )
 377        link_accounts_button_space = 70.0
 378
 379        show_unlink_accounts_button = show_link_accounts_button
 380        unlink_accounts_button_space = 90.0
 381
 382        # Phasing this out.
 383        show_v2_link_info = False
 384        v2_link_info_space = 70.0
 385
 386        legacy_unlink_button_space = 120.0
 387
 388        show_sign_out_button = primary_v2_account is not None or (
 389            self._v1_signed_in and v1_account_type == 'Local'
 390        )
 391        sign_out_button_space = 70.0
 392
 393        # We can show cancel if we're either waiting on an adapter to
 394        # provide us with v2 credentials or waiting for those
 395        # credentials to be verified.
 396        show_cancel_sign_in_button = self._signing_in_adapter is not None or (
 397            plus.accounts.have_primary_credentials()
 398            and primary_v2_account is None
 399        )
 400        cancel_sign_in_button_space = 70.0
 401
 402        if self._subcontainer is not None:
 403            self._subcontainer.delete()
 404        self._sub_height = 60.0
 405        if show_signed_in_as:
 406            self._sub_height += signed_in_as_space
 407        self._sub_height += via_space * len(via_lines)
 408        if show_signing_in_text:
 409            self._sub_height += signing_in_text_space
 410        if show_google_play_sign_in_button:
 411            self._sub_height += sign_in_button_space
 412        if show_game_center_sign_in_button:
 413            self._sub_height += sign_in_button_space
 414        if show_v2_proxy_sign_in_button:
 415            self._sub_height += sign_in_button_space
 416        if show_device_sign_in_button:
 417            self._sub_height += sign_in_button_space + deprecated_space
 418        if show_game_service_button:
 419            self._sub_height += game_service_button_space
 420        if show_linked_accounts_text:
 421            self._sub_height += linked_accounts_text_space
 422        if show_achievements_text:
 423            self._sub_height += achievements_text_space
 424        if show_leaderboards_button:
 425            self._sub_height += leaderboards_button_space
 426        if show_campaign_progress:
 427            self._sub_height += campaign_progress_space
 428        if show_tickets:
 429            self._sub_height += tickets_space
 430        if show_sign_in_benefits:
 431            self._sub_height += sign_in_benefits_space
 432        if show_manage_account_button:
 433            self._sub_height += manage_account_button_space
 434        if show_link_accounts_button:
 435            self._sub_height += link_accounts_button_space
 436        if show_unlink_accounts_button:
 437            self._sub_height += unlink_accounts_button_space
 438        if show_v2_link_info:
 439            self._sub_height += v2_link_info_space
 440        if self._show_legacy_unlink_button:
 441            self._sub_height += legacy_unlink_button_space
 442        if show_sign_out_button:
 443            self._sub_height += sign_out_button_space
 444        if show_delete_account_button:
 445            self._sub_height += delete_account_button_space
 446        if show_cancel_sign_in_button:
 447            self._sub_height += cancel_sign_in_button_space
 448        self._subcontainer = bui.containerwidget(
 449            parent=self._scrollwidget,
 450            size=(self._sub_width, self._sub_height),
 451            background=False,
 452            claims_left_right=True,
 453            claims_tab=True,
 454            selection_loops_to_parent=True,
 455        )
 456
 457        first_selectable = None
 458        v = self._sub_height - 10.0
 459
 460        assert bui.app.classic is not None
 461        self._account_name_text: bui.Widget | None
 462        if show_signed_in_as:
 463            v -= signed_in_as_space * 0.2
 464            txt = bui.Lstr(
 465                resource='accountSettingsWindow.youAreSignedInAsText',
 466                fallback_resource='accountSettingsWindow.youAreLoggedInAsText',
 467            )
 468            bui.textwidget(
 469                parent=self._subcontainer,
 470                position=(self._sub_width * 0.5, v),
 471                size=(0, 0),
 472                text=txt,
 473                scale=0.9,
 474                color=bui.app.ui_v1.title_color,
 475                maxwidth=self._sub_width * 0.9,
 476                h_align='center',
 477                v_align='center',
 478            )
 479            v -= signed_in_as_space * 0.5
 480            self._account_name_text = bui.textwidget(
 481                parent=self._subcontainer,
 482                position=(self._sub_width * 0.5, v),
 483                size=(0, 0),
 484                scale=1.5,
 485                maxwidth=self._sub_width * 0.9,
 486                res_scale=1.5,
 487                color=(1, 1, 1, 1),
 488                h_align='center',
 489                v_align='center',
 490            )
 491
 492            self._refresh_account_name_text()
 493
 494            v -= signed_in_as_space * 0.4
 495
 496            for via in via_lines:
 497                v -= via_space * 0.1
 498                sscale = 0.7
 499                swidth = (
 500                    bui.get_string_width(via, suppress_warning=True) * sscale
 501                )
 502                bui.textwidget(
 503                    parent=self._subcontainer,
 504                    position=(self._sub_width * 0.5, v),
 505                    size=(0, 0),
 506                    text=via,
 507                    scale=sscale,
 508                    color=(0.6, 0.6, 0.6),
 509                    flatness=1.0,
 510                    shadow=0.0,
 511                    h_align='center',
 512                    v_align='center',
 513                )
 514                bui.textwidget(
 515                    parent=self._subcontainer,
 516                    position=(self._sub_width * 0.5 - swidth * 0.5 - 5, v),
 517                    size=(0, 0),
 518                    text=bui.Lstr(
 519                        value='(${VIA}',
 520                        subs=[('${VIA}', bui.Lstr(resource='viaText'))],
 521                    ),
 522                    scale=0.5,
 523                    color=(0.4, 0.6, 0.4, 0.5),
 524                    flatness=1.0,
 525                    shadow=0.0,
 526                    h_align='right',
 527                    v_align='center',
 528                )
 529                bui.textwidget(
 530                    parent=self._subcontainer,
 531                    position=(self._sub_width * 0.5 + swidth * 0.5 + 10, v),
 532                    size=(0, 0),
 533                    text=')',
 534                    scale=0.5,
 535                    color=(0.4, 0.6, 0.4, 0.5),
 536                    flatness=1.0,
 537                    shadow=0.0,
 538                    h_align='right',
 539                    v_align='center',
 540                )
 541
 542                v -= via_space * 0.9
 543
 544        else:
 545            self._account_name_text = None
 546
 547        if self._back_button is None:
 548            bbtn = bui.get_special_widget('back_button')
 549        else:
 550            bbtn = self._back_button
 551
 552        if show_sign_in_benefits:
 553            v -= sign_in_benefits_space
 554            bui.textwidget(
 555                parent=self._subcontainer,
 556                position=(
 557                    self._sub_width * 0.5,
 558                    v + sign_in_benefits_space * 0.4,
 559                ),
 560                size=(0, 0),
 561                text=bui.Lstr(resource=f'{self._r}.signInInfoText'),
 562                max_height=sign_in_benefits_space * 0.9,
 563                scale=0.9,
 564                color=(0.75, 0.7, 0.8),
 565                maxwidth=self._sub_width * 0.8,
 566                h_align='center',
 567                v_align='center',
 568            )
 569
 570        if show_signing_in_text:
 571            v -= signing_in_text_space
 572
 573            bui.textwidget(
 574                parent=self._subcontainer,
 575                position=(
 576                    self._sub_width * 0.5,
 577                    v + signing_in_text_space * 0.5,
 578                ),
 579                size=(0, 0),
 580                text=bui.Lstr(resource='accountSettingsWindow.signingInText'),
 581                scale=0.9,
 582                color=(0, 1, 0),
 583                maxwidth=self._sub_width * 0.8,
 584                h_align='center',
 585                v_align='center',
 586            )
 587
 588        if show_google_play_sign_in_button:
 589            button_width = 350
 590            v -= sign_in_button_space
 591            self._sign_in_google_play_button = btn = bui.buttonwidget(
 592                parent=self._subcontainer,
 593                position=((self._sub_width - button_width) * 0.5, v - 20),
 594                autoselect=True,
 595                size=(button_width, 60),
 596                label=bui.Lstr(
 597                    value='${A} ${B}',
 598                    subs=[
 599                        (
 600                            '${A}',
 601                            bui.charstr(bui.SpecialChar.GOOGLE_PLAY_GAMES_LOGO),
 602                        ),
 603                        (
 604                            '${B}',
 605                            bui.Lstr(
 606                                resource=f'{self._r}.signInWithText',
 607                                subs=[
 608                                    (
 609                                        '${SERVICE}',
 610                                        bui.Lstr(resource='googlePlayText'),
 611                                    )
 612                                ],
 613                            ),
 614                        ),
 615                    ],
 616                ),
 617                on_activate_call=lambda: self._sign_in_press(LoginType.GPGS),
 618            )
 619            if first_selectable is None:
 620                first_selectable = btn
 621            bui.widget(
 622                edit=btn, right_widget=bui.get_special_widget('squad_button')
 623            )
 624            bui.widget(edit=btn, left_widget=bbtn)
 625            bui.widget(edit=btn, show_buffer_bottom=40, show_buffer_top=100)
 626            self._sign_in_text = None
 627
 628        if show_game_center_sign_in_button:
 629            button_width = 350
 630            v -= sign_in_button_space
 631            self._sign_in_google_play_button = btn = bui.buttonwidget(
 632                parent=self._subcontainer,
 633                position=((self._sub_width - button_width) * 0.5, v - 20),
 634                autoselect=True,
 635                size=(button_width, 60),
 636                # Note: Apparently Game Center is just called 'Game Center'
 637                # in all languages. Can revisit if not true.
 638                # https://developer.apple.com/forums/thread/725779
 639                label=bui.Lstr(
 640                    value='${A} ${B}',
 641                    subs=[
 642                        (
 643                            '${A}',
 644                            bui.charstr(bui.SpecialChar.GAME_CENTER_LOGO),
 645                        ),
 646                        (
 647                            '${B}',
 648                            bui.Lstr(
 649                                resource=f'{self._r}.signInWithText',
 650                                subs=[('${SERVICE}', 'Game Center')],
 651                            ),
 652                        ),
 653                    ],
 654                ),
 655                on_activate_call=lambda: self._sign_in_press(
 656                    LoginType.GAME_CENTER
 657                ),
 658            )
 659            if first_selectable is None:
 660                first_selectable = btn
 661            bui.widget(
 662                edit=btn, right_widget=bui.get_special_widget('squad_button')
 663            )
 664            bui.widget(edit=btn, left_widget=bbtn)
 665            bui.widget(edit=btn, show_buffer_bottom=40, show_buffer_top=100)
 666            self._sign_in_text = None
 667
 668        if show_v2_proxy_sign_in_button:
 669            button_width = 350
 670            v -= sign_in_button_space
 671            self._sign_in_v2_proxy_button = btn = bui.buttonwidget(
 672                parent=self._subcontainer,
 673                position=((self._sub_width - button_width) * 0.5, v - 20),
 674                autoselect=True,
 675                size=(button_width, 60),
 676                label='',
 677                on_activate_call=self._v2_proxy_sign_in_press,
 678            )
 679
 680            v2labeltext: bui.Lstr | str = (
 681                bui.Lstr(resource=f'{self._r}.signInWithAnEmailAddressText')
 682                if show_game_center_sign_in_button
 683                or show_google_play_sign_in_button
 684                or show_device_sign_in_button
 685                else bui.Lstr(resource=f'{self._r}.signInText')
 686            )
 687            v2infotext: bui.Lstr | str | None = None
 688
 689            bui.textwidget(
 690                parent=self._subcontainer,
 691                draw_controller=btn,
 692                h_align='center',
 693                v_align='center',
 694                size=(0, 0),
 695                position=(
 696                    self._sub_width * 0.5,
 697                    v + (17 if v2infotext is not None else 10),
 698                ),
 699                text=bui.Lstr(
 700                    value='${A} ${B}',
 701                    subs=[
 702                        ('${A}', bui.charstr(bui.SpecialChar.V2_LOGO)),
 703                        (
 704                            '${B}',
 705                            v2labeltext,
 706                        ),
 707                    ],
 708                ),
 709                maxwidth=button_width * 0.8,
 710                color=(0.75, 1.0, 0.7),
 711            )
 712            if v2infotext is not None:
 713                bui.textwidget(
 714                    parent=self._subcontainer,
 715                    draw_controller=btn,
 716                    h_align='center',
 717                    v_align='center',
 718                    size=(0, 0),
 719                    position=(self._sub_width * 0.5, v - 4),
 720                    text=v2infotext,
 721                    flatness=1.0,
 722                    scale=0.57,
 723                    maxwidth=button_width * 0.9,
 724                    color=(0.55, 0.8, 0.5),
 725                )
 726            if first_selectable is None:
 727                first_selectable = btn
 728            bui.widget(
 729                edit=btn, right_widget=bui.get_special_widget('squad_button')
 730            )
 731            bui.widget(edit=btn, left_widget=bbtn)
 732            bui.widget(edit=btn, show_buffer_bottom=40, show_buffer_top=100)
 733            self._sign_in_text = None
 734
 735        if show_device_sign_in_button:
 736            button_width = 350
 737            v -= sign_in_button_space + deprecated_space
 738            self._sign_in_device_button = btn = bui.buttonwidget(
 739                parent=self._subcontainer,
 740                position=((self._sub_width - button_width) * 0.5, v - 20),
 741                autoselect=True,
 742                size=(button_width, 60),
 743                label='',
 744                on_activate_call=lambda: self._sign_in_press('Local'),
 745            )
 746            bui.textwidget(
 747                parent=self._subcontainer,
 748                h_align='center',
 749                v_align='center',
 750                size=(0, 0),
 751                position=(self._sub_width * 0.5, v + 60),
 752                text=bui.Lstr(resource='deprecatedText'),
 753                scale=0.8,
 754                maxwidth=300,
 755                color=(0.6, 0.55, 0.45),
 756            )
 757
 758            bui.textwidget(
 759                parent=self._subcontainer,
 760                draw_controller=btn,
 761                h_align='center',
 762                v_align='center',
 763                size=(0, 0),
 764                position=(self._sub_width * 0.5, v + 17),
 765                text=bui.Lstr(
 766                    value='${A} ${B}',
 767                    subs=[
 768                        ('${A}', bui.charstr(bui.SpecialChar.LOCAL_ACCOUNT)),
 769                        (
 770                            '${B}',
 771                            bui.Lstr(
 772                                resource=f'{self._r}.signInWithDeviceText'
 773                            ),
 774                        ),
 775                    ],
 776                ),
 777                maxwidth=button_width * 0.8,
 778                color=(0.75, 1.0, 0.7),
 779            )
 780            bui.textwidget(
 781                parent=self._subcontainer,
 782                draw_controller=btn,
 783                h_align='center',
 784                v_align='center',
 785                size=(0, 0),
 786                position=(self._sub_width * 0.5, v - 4),
 787                text=bui.Lstr(resource=f'{self._r}.signInWithDeviceInfoText'),
 788                flatness=1.0,
 789                scale=0.57,
 790                maxwidth=button_width * 0.9,
 791                color=(0.55, 0.8, 0.5),
 792            )
 793            if first_selectable is None:
 794                first_selectable = btn
 795            bui.widget(
 796                edit=btn, right_widget=bui.get_special_widget('squad_button')
 797            )
 798            bui.widget(edit=btn, left_widget=bbtn)
 799            bui.widget(edit=btn, show_buffer_bottom=40, show_buffer_top=100)
 800            self._sign_in_text = None
 801
 802        if show_manage_account_button:
 803            button_width = 300
 804            v -= manage_account_button_space
 805            self._manage_button = btn = bui.buttonwidget(
 806                parent=self._subcontainer,
 807                position=((self._sub_width - button_width) * 0.5, v),
 808                autoselect=True,
 809                size=(button_width, 60),
 810                label=bui.Lstr(resource=f'{self._r}.manageAccountText'),
 811                color=(0.55, 0.5, 0.6),
 812                icon=bui.gettexture('settingsIcon'),
 813                textcolor=(0.75, 0.7, 0.8),
 814                on_activate_call=bui.WeakCall(self._on_manage_account_press),
 815            )
 816            if first_selectable is None:
 817                first_selectable = btn
 818            bui.widget(
 819                edit=btn, right_widget=bui.get_special_widget('squad_button')
 820            )
 821            bui.widget(edit=btn, left_widget=bbtn)
 822
 823        # the button to go to OS-Specific leaderboards/high-score-lists/etc.
 824        if show_game_service_button:
 825            button_width = 300
 826            v -= game_service_button_space * 0.6
 827            if game_center_active:
 828                # Note: Apparently Game Center is just called 'Game Center'
 829                # in all languages. Can revisit if not true.
 830                # https://developer.apple.com/forums/thread/725779
 831                game_service_button_label = bui.Lstr(
 832                    value=bui.charstr(bui.SpecialChar.GAME_CENTER_LOGO)
 833                    + 'Game Center'
 834                )
 835            else:
 836                raise ValueError(
 837                    "unknown account type: '" + str(v1_account_type) + "'"
 838                )
 839            self._game_service_button = btn = bui.buttonwidget(
 840                parent=self._subcontainer,
 841                position=((self._sub_width - button_width) * 0.5, v),
 842                color=(0.55, 0.5, 0.6),
 843                textcolor=(0.75, 0.7, 0.8),
 844                autoselect=True,
 845                on_activate_call=self._on_game_service_button_press,
 846                size=(button_width, 50),
 847                label=game_service_button_label,
 848            )
 849            if first_selectable is None:
 850                first_selectable = btn
 851            bui.widget(
 852                edit=btn, right_widget=bui.get_special_widget('squad_button')
 853            )
 854            bui.widget(edit=btn, left_widget=bbtn)
 855            v -= game_service_button_space * 0.4
 856        else:
 857            self.game_service_button = None
 858
 859        self._achievements_text: bui.Widget | None
 860        if show_achievements_text:
 861            v -= achievements_text_space * 0.5
 862            self._achievements_text = bui.textwidget(
 863                parent=self._subcontainer,
 864                position=(self._sub_width * 0.5, v),
 865                size=(0, 0),
 866                scale=0.9,
 867                color=(0.75, 0.7, 0.8),
 868                maxwidth=self._sub_width * 0.8,
 869                h_align='center',
 870                v_align='center',
 871            )
 872            v -= achievements_text_space * 0.5
 873        else:
 874            self._achievements_text = None
 875
 876        if show_achievements_text:
 877            self._refresh_achievements()
 878
 879        self._leaderboards_button: bui.Widget | None
 880        if show_leaderboards_button:
 881            button_width = 300
 882            v -= leaderboards_button_space * 0.85
 883            self._leaderboards_button = btn = bui.buttonwidget(
 884                parent=self._subcontainer,
 885                position=((self._sub_width - button_width) * 0.5, v),
 886                color=(0.55, 0.5, 0.6),
 887                textcolor=(0.75, 0.7, 0.8),
 888                autoselect=True,
 889                icon=bui.gettexture('googlePlayLeaderboardsIcon'),
 890                icon_color=(0.8, 0.95, 0.7),
 891                on_activate_call=self._on_leaderboards_press,
 892                size=(button_width, 50),
 893                label=bui.Lstr(resource='leaderboardsText'),
 894            )
 895            if first_selectable is None:
 896                first_selectable = btn
 897            bui.widget(
 898                edit=btn, right_widget=bui.get_special_widget('squad_button')
 899            )
 900            bui.widget(edit=btn, left_widget=bbtn)
 901            v -= leaderboards_button_space * 0.15
 902        else:
 903            self._leaderboards_button = None
 904
 905        self._campaign_progress_text: bui.Widget | None
 906        if show_campaign_progress:
 907            v -= campaign_progress_space * 0.5
 908            self._campaign_progress_text = bui.textwidget(
 909                parent=self._subcontainer,
 910                position=(self._sub_width * 0.5, v),
 911                size=(0, 0),
 912                scale=0.9,
 913                color=(0.75, 0.7, 0.8),
 914                maxwidth=self._sub_width * 0.8,
 915                h_align='center',
 916                v_align='center',
 917            )
 918            v -= campaign_progress_space * 0.5
 919            self._refresh_campaign_progress_text()
 920        else:
 921            self._campaign_progress_text = None
 922
 923        self._tickets_text: bui.Widget | None
 924        if show_tickets:
 925            v -= tickets_space * 0.5
 926            self._tickets_text = bui.textwidget(
 927                parent=self._subcontainer,
 928                position=(self._sub_width * 0.5, v),
 929                size=(0, 0),
 930                scale=0.9,
 931                color=(0.75, 0.7, 0.8),
 932                maxwidth=self._sub_width * 0.8,
 933                flatness=1.0,
 934                h_align='center',
 935                v_align='center',
 936            )
 937            v -= tickets_space * 0.5
 938            self._refresh_tickets_text()
 939
 940        else:
 941            self._tickets_text = None
 942
 943        # bit of spacing before the reset/sign-out section
 944        # v -= 5
 945
 946        button_width = 300
 947
 948        self._linked_accounts_text: bui.Widget | None
 949        if show_linked_accounts_text:
 950            v -= linked_accounts_text_space * 0.8
 951            self._linked_accounts_text = bui.textwidget(
 952                parent=self._subcontainer,
 953                position=(self._sub_width * 0.5, v),
 954                size=(0, 0),
 955                scale=0.9,
 956                color=(0.75, 0.7, 0.8),
 957                maxwidth=self._sub_width * 0.95,
 958                text=bui.Lstr(resource=f'{self._r}.linkedAccountsText'),
 959                h_align='center',
 960                v_align='center',
 961            )
 962            v -= linked_accounts_text_space * 0.2
 963            self._update_linked_accounts_text()
 964        else:
 965            self._linked_accounts_text = None
 966
 967        # Show link/unlink buttons only for V1 accounts.
 968
 969        if show_link_accounts_button:
 970            v -= link_accounts_button_space
 971            self._link_accounts_button = btn = bui.buttonwidget(
 972                parent=self._subcontainer,
 973                position=((self._sub_width - button_width) * 0.5, v),
 974                autoselect=True,
 975                size=(button_width, 60),
 976                label='',
 977                color=(0.55, 0.5, 0.6),
 978                on_activate_call=self._link_accounts_press,
 979            )
 980            bui.textwidget(
 981                parent=self._subcontainer,
 982                draw_controller=btn,
 983                h_align='center',
 984                v_align='center',
 985                size=(0, 0),
 986                position=(self._sub_width * 0.5, v + 17 + 20),
 987                text=bui.Lstr(resource=f'{self._r}.linkAccountsText'),
 988                maxwidth=button_width * 0.8,
 989                color=(0.75, 0.7, 0.8),
 990            )
 991            bui.textwidget(
 992                parent=self._subcontainer,
 993                draw_controller=btn,
 994                h_align='center',
 995                v_align='center',
 996                size=(0, 0),
 997                position=(self._sub_width * 0.5, v - 4 + 20),
 998                text=bui.Lstr(resource=f'{self._r}.linkAccountsInfoText'),
 999                flatness=1.0,
1000                scale=0.5,
1001                maxwidth=button_width * 0.8,
1002                color=(0.75, 0.7, 0.8),
1003            )
1004            if first_selectable is None:
1005                first_selectable = btn
1006            bui.widget(
1007                edit=btn, right_widget=bui.get_special_widget('squad_button')
1008            )
1009            bui.widget(edit=btn, left_widget=bbtn, show_buffer_bottom=50)
1010
1011        self._unlink_accounts_button: bui.Widget | None
1012        if show_unlink_accounts_button:
1013            v -= unlink_accounts_button_space
1014            self._unlink_accounts_button = btn = bui.buttonwidget(
1015                parent=self._subcontainer,
1016                position=((self._sub_width - button_width) * 0.5, v + 25),
1017                autoselect=True,
1018                size=(button_width, 60),
1019                label='',
1020                color=(0.55, 0.5, 0.6),
1021                on_activate_call=self._unlink_accounts_press,
1022            )
1023            self._unlink_accounts_button_label = bui.textwidget(
1024                parent=self._subcontainer,
1025                draw_controller=btn,
1026                h_align='center',
1027                v_align='center',
1028                size=(0, 0),
1029                position=(self._sub_width * 0.5, v + 55),
1030                text=bui.Lstr(resource=f'{self._r}.unlinkAccountsText'),
1031                maxwidth=button_width * 0.8,
1032                color=(0.75, 0.7, 0.8),
1033            )
1034            if first_selectable is None:
1035                first_selectable = btn
1036            bui.widget(
1037                edit=btn, right_widget=bui.get_special_widget('squad_button')
1038            )
1039            bui.widget(edit=btn, left_widget=bbtn, show_buffer_bottom=50)
1040            self._update_unlink_accounts_button()
1041        else:
1042            self._unlink_accounts_button = None
1043
1044        if show_v2_link_info:
1045            v -= v2_link_info_space
1046            bui.textwidget(
1047                parent=self._subcontainer,
1048                h_align='center',
1049                v_align='center',
1050                size=(0, 0),
1051                position=(self._sub_width * 0.5, v + v2_link_info_space - 20),
1052                text=bui.Lstr(resource='v2AccountLinkingInfoText'),
1053                flatness=1.0,
1054                scale=0.8,
1055                maxwidth=450,
1056                color=(0.5, 0.45, 0.55),
1057            )
1058
1059        if self._show_legacy_unlink_button:
1060            v -= legacy_unlink_button_space
1061            button_width_w = button_width * 1.5
1062            bui.textwidget(
1063                parent=self._subcontainer,
1064                position=(self._sub_width * 0.5 - 150.0, v + 75),
1065                size=(300.0, 60),
1066                text=bui.Lstr(resource='whatIsThisText'),
1067                scale=0.8,
1068                color=(0.3, 0.7, 0.05),
1069                maxwidth=200.0,
1070                h_align='center',
1071                v_align='center',
1072                autoselect=True,
1073                selectable=True,
1074                on_activate_call=show_what_is_legacy_unlinking_page,
1075                click_activate=True,
1076            )
1077            btn = bui.buttonwidget(
1078                parent=self._subcontainer,
1079                position=((self._sub_width - button_width_w) * 0.5, v + 25),
1080                autoselect=True,
1081                size=(button_width_w, 60),
1082                label=bui.Lstr(
1083                    resource=f'{self._r}.unlinkLegacyV1AccountsText'
1084                ),
1085                textcolor=(0.8, 0.4, 0),
1086                color=(0.55, 0.5, 0.6),
1087                on_activate_call=self._unlink_accounts_press,
1088            )
1089
1090        if show_sign_out_button:
1091            v -= sign_out_button_space
1092            self._sign_out_button = btn = bui.buttonwidget(
1093                parent=self._subcontainer,
1094                position=((self._sub_width - button_width) * 0.5, v),
1095                size=(button_width, 60),
1096                label=bui.Lstr(resource=f'{self._r}.signOutText'),
1097                color=(0.55, 0.5, 0.6),
1098                textcolor=(0.75, 0.7, 0.8),
1099                autoselect=True,
1100                on_activate_call=self._sign_out_press,
1101            )
1102            if first_selectable is None:
1103                first_selectable = btn
1104            bui.widget(
1105                edit=btn, right_widget=bui.get_special_widget('squad_button')
1106            )
1107            bui.widget(edit=btn, left_widget=bbtn, show_buffer_bottom=15)
1108
1109        if show_cancel_sign_in_button:
1110            v -= cancel_sign_in_button_space
1111            self._cancel_sign_in_button = btn = bui.buttonwidget(
1112                parent=self._subcontainer,
1113                position=((self._sub_width - button_width) * 0.5, v),
1114                size=(button_width, 60),
1115                label=bui.Lstr(resource='cancelText'),
1116                color=(0.55, 0.5, 0.6),
1117                textcolor=(0.75, 0.7, 0.8),
1118                autoselect=True,
1119                on_activate_call=self._cancel_sign_in_press,
1120            )
1121            if first_selectable is None:
1122                first_selectable = btn
1123            bui.widget(
1124                edit=btn, right_widget=bui.get_special_widget('squad_button')
1125            )
1126            bui.widget(edit=btn, left_widget=bbtn, show_buffer_bottom=15)
1127
1128        if show_delete_account_button:
1129            v -= delete_account_button_space
1130            self._delete_account_button = btn = bui.buttonwidget(
1131                parent=self._subcontainer,
1132                position=((self._sub_width - button_width) * 0.5, v),
1133                size=(button_width, 60),
1134                label=bui.Lstr(resource=f'{self._r}.deleteAccountText'),
1135                color=(0.85, 0.5, 0.6),
1136                textcolor=(0.9, 0.7, 0.8),
1137                autoselect=True,
1138                on_activate_call=self._on_delete_account_press,
1139            )
1140            if first_selectable is None:
1141                first_selectable = btn
1142            bui.widget(
1143                edit=btn, right_widget=bui.get_special_widget('squad_button')
1144            )
1145            bui.widget(edit=btn, left_widget=bbtn, show_buffer_bottom=15)
1146
1147        # Whatever the topmost selectable thing is, we want it to scroll all
1148        # the way up when we select it.
1149        if first_selectable is not None:
1150            bui.widget(
1151                edit=first_selectable, up_widget=bbtn, show_buffer_top=400
1152            )
1153            # (this should re-scroll us to the top..)
1154            bui.containerwidget(
1155                edit=self._subcontainer, visible_child=first_selectable
1156            )
1157        self._needs_refresh = False
1158
1159    def _on_game_service_button_press(self) -> None:
1160        if bui.app.plus is not None:
1161            bui.app.plus.show_game_service_ui()
1162        else:
1163            logging.warning(
1164                'game-service-ui not available without plus feature-set.'
1165            )
1166
1167    def _on_custom_achievements_press(self) -> None:
1168        if bui.app.plus is not None:
1169            bui.apptimer(
1170                0.15,
1171                bui.Call(bui.app.plus.show_game_service_ui, 'achievements'),
1172            )
1173        else:
1174            logging.warning('show_game_service_ui requires plus feature-set.')
1175
1176    def _on_manage_account_press(self) -> None:
1177        self._do_manage_account_press(WebLocation.ACCOUNT_EDITOR)
1178
1179    def _on_delete_account_press(self) -> None:
1180        self._do_manage_account_press(WebLocation.ACCOUNT_DELETE_SECTION)
1181
1182    def _do_manage_account_press(self, weblocation: WebLocation) -> None:
1183        plus = bui.app.plus
1184        assert plus is not None
1185
1186        # Preemptively fail if it looks like we won't be able to talk to
1187        # the server anyway.
1188        if not plus.cloud.connected:
1189            bui.screenmessage(
1190                bui.Lstr(resource='internal.unavailableNoConnectionText'),
1191                color=(1, 0, 0),
1192            )
1193            bui.getsound('error').play()
1194            return
1195
1196        bui.screenmessage(bui.Lstr(resource='oneMomentText'))
1197
1198        # We expect to have a v2 account signed in if we get here.
1199        if plus.accounts.primary is None:
1200            logging.exception(
1201                'got manage-account press without v2 account present'
1202            )
1203            return
1204
1205        with plus.accounts.primary:
1206            plus.cloud.send_message_cb(
1207                bacommon.cloud.ManageAccountMessage(weblocation=weblocation),
1208                on_response=bui.WeakCall(self._on_manage_account_response),
1209            )
1210
1211    def _on_manage_account_response(
1212        self, response: bacommon.cloud.ManageAccountResponse | Exception
1213    ) -> None:
1214        if isinstance(response, Exception) or response.url is None:
1215            logging.warning(
1216                'Got error in manage-account-response: %s.', response
1217            )
1218            bui.screenmessage(bui.Lstr(resource='errorText'), color=(1, 0, 0))
1219            bui.getsound('error').play()
1220            return
1221
1222        bui.open_url(response.url)
1223
1224    def _on_leaderboards_press(self) -> None:
1225        if bui.app.plus is not None:
1226            bui.apptimer(
1227                0.15,
1228                bui.Call(bui.app.plus.show_game_service_ui, 'leaderboards'),
1229            )
1230        else:
1231            logging.warning('show_game_service_ui requires classic')
1232
1233    def _have_unlinkable_v1_accounts(self) -> bool:
1234        plus = bui.app.plus
1235        assert plus is not None
1236
1237        # if this is not present, we haven't had contact from the server so
1238        # let's not proceed..
1239        if plus.get_v1_account_public_login_id() is None:
1240            return False
1241        accounts = plus.get_v1_account_misc_read_val_2('linkedAccounts', [])
1242        return len(accounts) > 1
1243
1244    def _update_unlink_accounts_button(self) -> None:
1245        if self._unlink_accounts_button is None:
1246            return
1247        if self._have_unlinkable_v1_accounts():
1248            clr = (0.75, 0.7, 0.8, 1.0)
1249        else:
1250            clr = (1.0, 1.0, 1.0, 0.25)
1251        bui.textwidget(edit=self._unlink_accounts_button_label, color=clr)
1252
1253    def _should_show_legacy_unlink_button(self) -> bool:
1254        plus = bui.app.plus
1255        assert plus is not None
1256
1257        # Only show this when fully signed in to a v2 account.
1258        if not self._v1_signed_in or plus.accounts.primary is None:
1259            return False
1260
1261        out = self._have_unlinkable_v1_accounts()
1262        return out
1263
1264    def _update_linked_accounts_text(self) -> None:
1265        plus = bui.app.plus
1266        assert plus is not None
1267
1268        if self._linked_accounts_text is None:
1269            return
1270
1271        # Disable this by default when signed in to a V2 account
1272        # (since this shows V1 links which we should no longer care about).
1273        if plus.accounts.primary is not None and not FORCE_ENABLE_V1_LINKING:
1274            return
1275
1276        # if this is not present, we haven't had contact from the server so
1277        # let's not proceed..
1278        if plus.get_v1_account_public_login_id() is None:
1279            num = int(time.time()) % 4
1280            accounts_str = num * '.' + (4 - num) * ' '
1281        else:
1282            accounts = plus.get_v1_account_misc_read_val_2('linkedAccounts', [])
1283            # UPDATE - we now just print the number here; not the actual
1284            # accounts (they can see that in the unlink section if they're
1285            # curious)
1286            accounts_str = str(max(0, len(accounts) - 1))
1287        bui.textwidget(
1288            edit=self._linked_accounts_text,
1289            text=bui.Lstr(
1290                value='${L} ${A}',
1291                subs=[
1292                    (
1293                        '${L}',
1294                        bui.Lstr(resource=f'{self._r}.linkedAccountsText'),
1295                    ),
1296                    ('${A}', accounts_str),
1297                ],
1298            ),
1299        )
1300
1301    def _refresh_campaign_progress_text(self) -> None:
1302        if self._campaign_progress_text is None:
1303            return
1304        p_str: str | bui.Lstr
1305        try:
1306            assert bui.app.classic is not None
1307            campaign = bui.app.classic.getcampaign('Default')
1308            levels = campaign.levels
1309            levels_complete = sum((1 if l.complete else 0) for l in levels)
1310
1311            # Last level cant be completed; hence the -1;
1312            progress = min(1.0, float(levels_complete) / (len(levels) - 1))
1313            p_str = bui.Lstr(
1314                resource=f'{self._r}.campaignProgressText',
1315                subs=[('${PROGRESS}', str(int(progress * 100.0)) + '%')],
1316            )
1317        except Exception:
1318            p_str = '?'
1319            logging.exception('Error calculating co-op campaign progress.')
1320        bui.textwidget(edit=self._campaign_progress_text, text=p_str)
1321
1322    def _refresh_tickets_text(self) -> None:
1323        plus = bui.app.plus
1324        assert plus is not None
1325
1326        if self._tickets_text is None:
1327            return
1328        try:
1329            tc_str = str(plus.get_v1_account_ticket_count())
1330        except Exception:
1331            logging.exception('error refreshing tickets text')
1332            tc_str = '-'
1333        bui.textwidget(
1334            edit=self._tickets_text,
1335            text=bui.Lstr(
1336                resource=f'{self._r}.ticketsText', subs=[('${COUNT}', tc_str)]
1337            ),
1338        )
1339
1340    def _refresh_account_name_text(self) -> None:
1341        plus = bui.app.plus
1342        assert plus is not None
1343
1344        if self._account_name_text is None:
1345            return
1346        try:
1347            name_str = plus.get_v1_account_display_string()
1348        except Exception:
1349            logging.exception('error refreshing tickets text')
1350            name_str = '??'
1351
1352        bui.textwidget(edit=self._account_name_text, text=name_str)
1353
1354    def _refresh_achievements(self) -> None:
1355        assert bui.app.classic is not None
1356        if self._achievements_text is None:
1357            return
1358        complete = sum(
1359            1 if a.complete else 0 for a in bui.app.classic.ach.achievements
1360        )
1361        total = len(bui.app.classic.ach.achievements)
1362        txt_final = bui.Lstr(
1363            resource=f'{self._r}.achievementProgressText',
1364            subs=[('${COUNT}', str(complete)), ('${TOTAL}', str(total))],
1365        )
1366
1367        if self._achievements_text is not None:
1368            bui.textwidget(edit=self._achievements_text, text=txt_final)
1369
1370    def _link_accounts_press(self) -> None:
1371        # pylint: disable=cyclic-import
1372        from bauiv1lib.account.link import AccountLinkWindow
1373
1374        AccountLinkWindow(origin_widget=self._link_accounts_button)
1375
1376    def _unlink_accounts_press(self) -> None:
1377        # pylint: disable=cyclic-import
1378        from bauiv1lib.account.unlink import AccountUnlinkWindow
1379
1380        if not self._have_unlinkable_v1_accounts():
1381            bui.getsound('error').play()
1382            return
1383
1384        AccountUnlinkWindow(origin_widget=self._unlink_accounts_button)
1385
1386    def _cancel_sign_in_press(self) -> None:
1387        # If we're waiting on an adapter to give us credentials, abort.
1388        self._signing_in_adapter = None
1389
1390        plus = bui.app.plus
1391        assert plus is not None
1392
1393        # Say we don't wanna be signed in anymore if we are.
1394        plus.accounts.set_primary_credentials(None)
1395
1396        self._needs_refresh = True
1397
1398        # Speed UI updates along.
1399        bui.apptimer(0.1, bui.WeakCall(self._update))
1400
1401    def _sign_out_press(self) -> None:
1402        plus = bui.app.plus
1403        assert plus is not None
1404
1405        if plus.accounts.have_primary_credentials():
1406            if (
1407                plus.accounts.primary is not None
1408                and LoginType.GPGS in plus.accounts.primary.logins
1409            ):
1410                self._explicitly_signed_out_of_gpgs = True
1411            plus.accounts.set_primary_credentials(None)
1412        else:
1413            plus.sign_out_v1()
1414
1415        cfg = bui.app.config
1416
1417        # Also take note that its our *explicit* intention to not be
1418        # signed in at this point (affects v1 accounts).
1419        cfg['Auto Account State'] = 'signed_out'
1420        cfg.commit()
1421        bui.buttonwidget(
1422            edit=self._sign_out_button,
1423            label=bui.Lstr(resource=f'{self._r}.signingOutText'),
1424        )
1425
1426        # Speed UI updates along.
1427        bui.apptimer(0.1, bui.WeakCall(self._update))
1428
1429    def _sign_in_press(self, login_type: str | LoginType) -> None:
1430        from bauiv1lib.connectivity import wait_for_connectivity
1431
1432        # If we're still waiting for our master-server connection,
1433        # keep the user informed of this instead of rushing in and
1434        # failing immediately.
1435        wait_for_connectivity(on_connected=lambda: self._sign_in(login_type))
1436
1437    def _sign_in(self, login_type: str | LoginType) -> None:
1438        plus = bui.app.plus
1439        assert plus is not None
1440
1441        # V1 login types are strings.
1442        if isinstance(login_type, str):
1443            plus.sign_in_v1(login_type)
1444
1445            # Make note of the type account we're *wanting*
1446            # to be signed in with.
1447            cfg = bui.app.config
1448            cfg['Auto Account State'] = login_type
1449            cfg.commit()
1450            self._needs_refresh = True
1451            bui.apptimer(0.1, bui.WeakCall(self._update))
1452            return
1453
1454        # V2 login sign-in buttons generally go through adapters.
1455        adapter = plus.accounts.login_adapters.get(login_type)
1456        if adapter is not None:
1457            self._signing_in_adapter = adapter
1458            adapter.sign_in(
1459                result_cb=bui.WeakCall(self._on_adapter_sign_in_result),
1460                description='account settings button',
1461            )
1462            # Will get 'Signing in...' to show.
1463            self._needs_refresh = True
1464            bui.apptimer(0.1, bui.WeakCall(self._update))
1465        else:
1466            bui.screenmessage(f'Unsupported login_type: {login_type.name}')
1467
1468    def _on_adapter_sign_in_result(
1469        self,
1470        adapter: bui.LoginAdapter,
1471        result: bui.LoginAdapter.SignInResult | Exception,
1472    ) -> None:
1473        is_us = self._signing_in_adapter is adapter
1474
1475        # If this isn't our current one we don't care.
1476        if not is_us:
1477            return
1478
1479        # If it is us, note that we're done.
1480        self._signing_in_adapter = None
1481
1482        if isinstance(result, Exception):
1483            # For now just make a bit of noise if anything went wrong;
1484            # can get more specific as needed later.
1485            logging.warning('Got error in v2 sign-in result: %s', result)
1486            bui.screenmessage(
1487                bui.Lstr(resource='internal.signInNoConnectionText'),
1488                color=(1, 0, 0),
1489            )
1490            bui.getsound('error').play()
1491        else:
1492            # Success! Plug in these credentials which will begin
1493            # verifying them and set our primary account-handle when
1494            # finished.
1495            plus = bui.app.plus
1496            assert plus is not None
1497            plus.accounts.set_primary_credentials(result.credentials)
1498
1499            # Special case - if the user has explicitly signed out and
1500            # signed in again with GPGS via this button, warn them that
1501            # they need to use the app if they want to switch to a
1502            # different GPGS account.
1503            if (
1504                self._explicitly_signed_out_of_gpgs
1505                and adapter.login_type is LoginType.GPGS
1506            ):
1507                # Delay this slightly so it hopefully pops up after
1508                # credentials go through and the account name shows up.
1509                bui.apptimer(
1510                    1.5,
1511                    bui.Call(
1512                        bui.screenmessage,
1513                        bui.Lstr(
1514                            resource=self._r
1515                            + '.googlePlayGamesAccountSwitchText'
1516                        ),
1517                    ),
1518                )
1519
1520        # Speed any UI updates along.
1521        self._needs_refresh = True
1522        bui.apptimer(0.1, bui.WeakCall(self._update))
1523
1524    def _v2_proxy_sign_in_press(self) -> None:
1525        # pylint: disable=cyclic-import
1526        from bauiv1lib.connectivity import wait_for_connectivity
1527
1528        # If we're still waiting for our master-server connection, keep
1529        # the user informed of this instead of rushing in and failing
1530        # immediately.
1531        wait_for_connectivity(on_connected=self._v2_proxy_sign_in)
1532
1533    def _v2_proxy_sign_in(self) -> None:
1534        # pylint: disable=cyclic-import
1535        from bauiv1lib.account.v2proxy import V2ProxySignInWindow
1536
1537        assert self._sign_in_v2_proxy_button is not None
1538        V2ProxySignInWindow(origin_widget=self._sign_in_v2_proxy_button)
1539
1540    def _save_state(self) -> None:
1541        try:
1542            sel = self._root_widget.get_selected_child()
1543            if sel == self._back_button:
1544                sel_name = 'Back'
1545            elif sel == self._scrollwidget:
1546                sel_name = 'Scroll'
1547            else:
1548                raise ValueError('unrecognized selection')
1549            assert bui.app.classic is not None
1550            bui.app.ui_v1.window_states[type(self)] = sel_name
1551        except Exception:
1552            logging.exception('Error saving state for %s.', self)
1553
1554    def _restore_state(self) -> None:
1555        try:
1556            assert bui.app.classic is not None
1557            sel_name = bui.app.ui_v1.window_states.get(type(self))
1558            if sel_name == 'Back':
1559                sel = self._back_button
1560            elif sel_name == 'Scroll':
1561                sel = self._scrollwidget
1562            else:
1563                sel = self._back_button
1564            bui.containerwidget(edit=self._root_widget, selected_child=sel)
1565        except Exception:
1566            logging.exception('Error restoring state for %s.', self)
1567
1568
1569def show_what_is_legacy_unlinking_page() -> None:
1570    """Show the webpage describing legacy unlinking."""
1571    plus = bui.app.plus
1572    assert plus is not None
1573
1574    bamasteraddr = plus.get_master_server_address(version=2)
1575    bui.open_url(f'{bamasteraddr}/whatarev1links')
FORCE_ENABLE_V1_LINKING = False
class AccountSettingsWindow(bauiv1._uitypes.MainWindow):
  25class AccountSettingsWindow(bui.MainWindow):
  26    """Window for account related functionality."""
  27
  28    def __init__(
  29        self,
  30        transition: str | None = 'in_right',
  31        modal: bool = False,
  32        origin_widget: bui.Widget | None = None,
  33        close_once_signed_in: bool = False,
  34    ):
  35        # pylint: disable=too-many-statements
  36
  37        plus = bui.app.plus
  38        assert plus is not None
  39
  40        self._sign_in_v2_proxy_button: bui.Widget | None = None
  41        self._sign_in_device_button: bui.Widget | None = None
  42
  43        self._show_legacy_unlink_button = False
  44
  45        self._signing_in_adapter: bui.LoginAdapter | None = None
  46        self._close_once_signed_in = close_once_signed_in
  47        bui.set_analytics_screen('Account Window')
  48
  49        self._explicitly_signed_out_of_gpgs = False
  50
  51        self._r = 'accountSettingsWindow'
  52        self._modal = modal
  53        self._needs_refresh = False
  54        self._v1_signed_in = plus.get_v1_account_state() == 'signed_in'
  55        self._v1_account_state_num = plus.get_v1_account_state_num()
  56        self._check_sign_in_timer = bui.AppTimer(
  57            1.0, bui.WeakCall(self._update), repeat=True
  58        )
  59
  60        self._can_reset_achievements = False
  61
  62        app = bui.app
  63        assert app.classic is not None
  64        uiscale = app.ui_v1.uiscale
  65
  66        self._width = 850 if uiscale is bui.UIScale.SMALL else 660
  67        x_offs = 70 if uiscale is bui.UIScale.SMALL else 0
  68        self._height = (
  69            380
  70            if uiscale is bui.UIScale.SMALL
  71            else 430 if uiscale is bui.UIScale.MEDIUM else 490
  72        )
  73
  74        self._sign_in_button = None
  75        self._sign_in_text = None
  76
  77        self._scroll_width = self._width - (100 + x_offs * 2)
  78        self._scroll_height = self._height - 120
  79        self._sub_width = self._scroll_width - 20
  80
  81        # Determine which sign-in/sign-out buttons we should show.
  82        self._show_sign_in_buttons: list[str] = []
  83
  84        if LoginType.GPGS in plus.accounts.login_adapters:
  85            self._show_sign_in_buttons.append('Google Play')
  86
  87        if LoginType.GAME_CENTER in plus.accounts.login_adapters:
  88            self._show_sign_in_buttons.append('Game Center')
  89
  90        # Always want to show our web-based v2 login option.
  91        self._show_sign_in_buttons.append('V2Proxy')
  92
  93        # Legacy v1 device accounts available only if the user
  94        # has explicitly enabled deprecated login types.
  95        if bui.app.config.resolve('Show Deprecated Login Types'):
  96            self._show_sign_in_buttons.append('Device')
  97
  98        top_extra = 15 if uiscale is bui.UIScale.SMALL else 0
  99        super().__init__(
 100            root_widget=bui.containerwidget(
 101                size=(self._width, self._height + top_extra),
 102                toolbar_visibility=(
 103                    'menu_minimal'
 104                    if uiscale is bui.UIScale.SMALL
 105                    else 'menu_full'
 106                ),
 107                scale=(
 108                    2.07
 109                    if uiscale is bui.UIScale.SMALL
 110                    else 1.4 if uiscale is bui.UIScale.MEDIUM else 1.0
 111                ),
 112                stack_offset=(
 113                    (0, 8) if uiscale is bui.UIScale.SMALL else (0, 0)
 114                ),
 115            ),
 116            transition=transition,
 117            origin_widget=origin_widget,
 118        )
 119        if uiscale is bui.UIScale.SMALL:
 120            self._back_button = None
 121            bui.containerwidget(
 122                edit=self._root_widget, on_cancel_call=self.main_window_back
 123            )
 124        else:
 125            self._back_button = btn = bui.buttonwidget(
 126                parent=self._root_widget,
 127                position=(51 + x_offs, self._height - 62),
 128                size=(120, 60),
 129                scale=0.8,
 130                text_scale=1.2,
 131                autoselect=True,
 132                label=bui.Lstr(
 133                    resource='cancelText' if self._modal else 'backText'
 134                ),
 135                button_type='regular' if self._modal else 'back',
 136                on_activate_call=(
 137                    self._modal_close if self._modal else self.main_window_back
 138                ),
 139            )
 140            bui.containerwidget(edit=self._root_widget, cancel_button=btn)
 141            if not self._modal:
 142                bui.buttonwidget(
 143                    edit=btn,
 144                    button_type='backSmall',
 145                    size=(60, 56),
 146                    label=bui.charstr(bui.SpecialChar.BACK),
 147                )
 148
 149        titleyoffs = -12 if uiscale is bui.UIScale.SMALL else 0
 150        titlescale = 0.6 if uiscale is bui.UIScale.SMALL else 1.0
 151        bui.textwidget(
 152            parent=self._root_widget,
 153            position=(self._width * 0.5, self._height - 41 + titleyoffs),
 154            size=(0, 0),
 155            text=bui.Lstr(resource=f'{self._r}.titleText'),
 156            color=app.ui_v1.title_color,
 157            scale=titlescale,
 158            maxwidth=self._width - 340,
 159            h_align='center',
 160            v_align='center',
 161        )
 162
 163        self._scrollwidget = bui.scrollwidget(
 164            parent=self._root_widget,
 165            highlight=False,
 166            position=(
 167                (self._width - self._scroll_width) * 0.5,
 168                self._height - 65 - self._scroll_height,
 169            ),
 170            size=(self._scroll_width, self._scroll_height),
 171            claims_left_right=True,
 172            claims_tab=True,
 173            selection_loops_to_parent=True,
 174        )
 175        self._subcontainer: bui.Widget | None = None
 176        self._refresh()
 177        self._restore_state()
 178
 179    def _modal_close(self) -> None:
 180        assert self._modal
 181
 182        # no-op if our underlying widget is dead or on its way out.
 183        if not self._root_widget or self._root_widget.transitioning_out:
 184            return
 185
 186        bui.containerwidget(
 187            edit=self._root_widget,
 188            transition=('out_right'),
 189        )
 190
 191    @override
 192    def get_main_window_state(self) -> bui.MainWindowState:
 193        # Support recreating our window for back/refresh purposes.
 194        cls = type(self)
 195        return bui.BasicMainWindowState(
 196            create_call=lambda transition, origin_widget: cls(
 197                transition=transition, origin_widget=origin_widget
 198            )
 199        )
 200
 201    @override
 202    def on_main_window_close(self) -> None:
 203        self._save_state()
 204
 205    def _update(self) -> None:
 206        plus = bui.app.plus
 207        assert plus is not None
 208
 209        # If they want us to close once we're signed in, do so.
 210        if self._close_once_signed_in and self._v1_signed_in:
 211            self.main_window_back()
 212            return
 213
 214        # Hmm should update this to use get_account_state_num.
 215        # Theoretically if we switch from one signed-in account to another
 216        # in the background this would break.
 217        v1_account_state_num = plus.get_v1_account_state_num()
 218        v1_account_state = plus.get_v1_account_state()
 219        show_legacy_unlink_button = self._should_show_legacy_unlink_button()
 220
 221        if (
 222            v1_account_state_num != self._v1_account_state_num
 223            or show_legacy_unlink_button != self._show_legacy_unlink_button
 224            or self._needs_refresh
 225        ):
 226            self._v1_account_state_num = v1_account_state_num
 227            self._v1_signed_in = v1_account_state == 'signed_in'
 228            self._show_legacy_unlink_button = show_legacy_unlink_button
 229            self._refresh()
 230
 231        # Go ahead and refresh some individual things
 232        # that may change under us.
 233        self._update_linked_accounts_text()
 234        self._update_unlink_accounts_button()
 235        self._refresh_campaign_progress_text()
 236        self._refresh_achievements()
 237        self._refresh_tickets_text()
 238        self._refresh_account_name_text()
 239
 240    def _refresh(self) -> None:
 241        # pylint: disable=too-many-statements
 242        # pylint: disable=too-many-branches
 243        # pylint: disable=too-many-locals
 244        # pylint: disable=cyclic-import
 245
 246        plus = bui.app.plus
 247        assert plus is not None
 248
 249        via_lines: list[str] = []
 250
 251        primary_v2_account = plus.accounts.primary
 252
 253        v1_state = plus.get_v1_account_state()
 254        v1_account_type = (
 255            plus.get_v1_account_type() if v1_state == 'signed_in' else 'unknown'
 256        )
 257
 258        # We expose GPGS-specific functionality only if it is 'active'
 259        # (meaning the current GPGS player matches one of our account's
 260        # logins).
 261        adapter = plus.accounts.login_adapters.get(LoginType.GPGS)
 262        gpgs_active = adapter is not None and adapter.is_back_end_active()
 263
 264        # Ditto for Game Center.
 265        adapter = plus.accounts.login_adapters.get(LoginType.GAME_CENTER)
 266        game_center_active = (
 267            adapter is not None and adapter.is_back_end_active()
 268        )
 269
 270        show_signed_in_as = self._v1_signed_in
 271        signed_in_as_space = 95.0
 272
 273        # To reduce confusion about the whole V2 account situation for
 274        # people used to seeing their Google Play Games or Game Center
 275        # account name and icon and whatnot, let's show those underneath
 276        # the V2 tag to help communicate that they are in fact logged in
 277        # through that account.
 278        via_space = 25.0
 279        if show_signed_in_as and bui.app.plus is not None:
 280            accounts = bui.app.plus.accounts
 281            if accounts.primary is not None:
 282                # For these login types, we show 'via' IF there is a
 283                # login of that type attached to our account AND it is
 284                # currently active (We don't want to show 'via Game
 285                # Center' if we're signed out of Game Center or
 286                # currently running on Steam, even if there is a Game
 287                # Center login attached to our account).
 288                for ltype, lchar in [
 289                    (LoginType.GPGS, bui.SpecialChar.GOOGLE_PLAY_GAMES_LOGO),
 290                    (LoginType.GAME_CENTER, bui.SpecialChar.GAME_CENTER_LOGO),
 291                ]:
 292                    linfo = accounts.primary.logins.get(ltype)
 293                    ladapter = accounts.login_adapters.get(ltype)
 294                    if (
 295                        linfo is not None
 296                        and ladapter is not None
 297                        and ladapter.is_back_end_active()
 298                    ):
 299                        via_lines.append(f'{bui.charstr(lchar)}{linfo.name}')
 300
 301                # TEMP TESTING
 302                if bool(False):
 303                    icontxt = bui.charstr(bui.SpecialChar.GAME_CENTER_LOGO)
 304                    via_lines.append(f'{icontxt}FloofDibble')
 305                    icontxt = bui.charstr(
 306                        bui.SpecialChar.GOOGLE_PLAY_GAMES_LOGO
 307                    )
 308                    via_lines.append(f'{icontxt}StinkBobble')
 309
 310        show_sign_in_benefits = not self._v1_signed_in
 311        sign_in_benefits_space = 80.0
 312
 313        show_signing_in_text = (
 314            v1_state == 'signing_in' or self._signing_in_adapter is not None
 315        )
 316        signing_in_text_space = 80.0
 317
 318        show_google_play_sign_in_button = (
 319            v1_state == 'signed_out'
 320            and self._signing_in_adapter is None
 321            and 'Google Play' in self._show_sign_in_buttons
 322        )
 323        show_game_center_sign_in_button = (
 324            v1_state == 'signed_out'
 325            and self._signing_in_adapter is None
 326            and 'Game Center' in self._show_sign_in_buttons
 327        )
 328        show_v2_proxy_sign_in_button = (
 329            v1_state == 'signed_out'
 330            and self._signing_in_adapter is None
 331            and 'V2Proxy' in self._show_sign_in_buttons
 332        )
 333        show_device_sign_in_button = (
 334            v1_state == 'signed_out'
 335            and self._signing_in_adapter is None
 336            and 'Device' in self._show_sign_in_buttons
 337        )
 338        sign_in_button_space = 70.0
 339        deprecated_space = 60
 340
 341        # Game Center currently has a single UI for everything.
 342        show_game_service_button = game_center_active
 343        game_service_button_space = 60.0
 344
 345        # Phasing this out (for V2 accounts at least).
 346        show_linked_accounts_text = (
 347            self._v1_signed_in and v1_account_type != 'V2'
 348        )
 349        linked_accounts_text_space = 60.0
 350
 351        # Update: No longer showing this since its visible on main
 352        # toolbar.
 353        show_achievements_text = False
 354        achievements_text_space = 27.0
 355
 356        show_leaderboards_button = self._v1_signed_in and gpgs_active
 357        leaderboards_button_space = 60.0
 358
 359        # Update: No longer showing this; trying to get progress type
 360        # stuff out of the account panel.
 361        # show_campaign_progress = self._v1_signed_in
 362        show_campaign_progress = False
 363        campaign_progress_space = 27.0
 364
 365        # show_tickets = self._v1_signed_in
 366        show_tickets = False
 367        tickets_space = 27.0
 368
 369        show_manage_account_button = primary_v2_account is not None
 370        manage_account_button_space = 70.0
 371
 372        show_delete_account_button = primary_v2_account is not None
 373        delete_account_button_space = 70.0
 374
 375        show_link_accounts_button = self._v1_signed_in and (
 376            primary_v2_account is None or FORCE_ENABLE_V1_LINKING
 377        )
 378        link_accounts_button_space = 70.0
 379
 380        show_unlink_accounts_button = show_link_accounts_button
 381        unlink_accounts_button_space = 90.0
 382
 383        # Phasing this out.
 384        show_v2_link_info = False
 385        v2_link_info_space = 70.0
 386
 387        legacy_unlink_button_space = 120.0
 388
 389        show_sign_out_button = primary_v2_account is not None or (
 390            self._v1_signed_in and v1_account_type == 'Local'
 391        )
 392        sign_out_button_space = 70.0
 393
 394        # We can show cancel if we're either waiting on an adapter to
 395        # provide us with v2 credentials or waiting for those
 396        # credentials to be verified.
 397        show_cancel_sign_in_button = self._signing_in_adapter is not None or (
 398            plus.accounts.have_primary_credentials()
 399            and primary_v2_account is None
 400        )
 401        cancel_sign_in_button_space = 70.0
 402
 403        if self._subcontainer is not None:
 404            self._subcontainer.delete()
 405        self._sub_height = 60.0
 406        if show_signed_in_as:
 407            self._sub_height += signed_in_as_space
 408        self._sub_height += via_space * len(via_lines)
 409        if show_signing_in_text:
 410            self._sub_height += signing_in_text_space
 411        if show_google_play_sign_in_button:
 412            self._sub_height += sign_in_button_space
 413        if show_game_center_sign_in_button:
 414            self._sub_height += sign_in_button_space
 415        if show_v2_proxy_sign_in_button:
 416            self._sub_height += sign_in_button_space
 417        if show_device_sign_in_button:
 418            self._sub_height += sign_in_button_space + deprecated_space
 419        if show_game_service_button:
 420            self._sub_height += game_service_button_space
 421        if show_linked_accounts_text:
 422            self._sub_height += linked_accounts_text_space
 423        if show_achievements_text:
 424            self._sub_height += achievements_text_space
 425        if show_leaderboards_button:
 426            self._sub_height += leaderboards_button_space
 427        if show_campaign_progress:
 428            self._sub_height += campaign_progress_space
 429        if show_tickets:
 430            self._sub_height += tickets_space
 431        if show_sign_in_benefits:
 432            self._sub_height += sign_in_benefits_space
 433        if show_manage_account_button:
 434            self._sub_height += manage_account_button_space
 435        if show_link_accounts_button:
 436            self._sub_height += link_accounts_button_space
 437        if show_unlink_accounts_button:
 438            self._sub_height += unlink_accounts_button_space
 439        if show_v2_link_info:
 440            self._sub_height += v2_link_info_space
 441        if self._show_legacy_unlink_button:
 442            self._sub_height += legacy_unlink_button_space
 443        if show_sign_out_button:
 444            self._sub_height += sign_out_button_space
 445        if show_delete_account_button:
 446            self._sub_height += delete_account_button_space
 447        if show_cancel_sign_in_button:
 448            self._sub_height += cancel_sign_in_button_space
 449        self._subcontainer = bui.containerwidget(
 450            parent=self._scrollwidget,
 451            size=(self._sub_width, self._sub_height),
 452            background=False,
 453            claims_left_right=True,
 454            claims_tab=True,
 455            selection_loops_to_parent=True,
 456        )
 457
 458        first_selectable = None
 459        v = self._sub_height - 10.0
 460
 461        assert bui.app.classic is not None
 462        self._account_name_text: bui.Widget | None
 463        if show_signed_in_as:
 464            v -= signed_in_as_space * 0.2
 465            txt = bui.Lstr(
 466                resource='accountSettingsWindow.youAreSignedInAsText',
 467                fallback_resource='accountSettingsWindow.youAreLoggedInAsText',
 468            )
 469            bui.textwidget(
 470                parent=self._subcontainer,
 471                position=(self._sub_width * 0.5, v),
 472                size=(0, 0),
 473                text=txt,
 474                scale=0.9,
 475                color=bui.app.ui_v1.title_color,
 476                maxwidth=self._sub_width * 0.9,
 477                h_align='center',
 478                v_align='center',
 479            )
 480            v -= signed_in_as_space * 0.5
 481            self._account_name_text = bui.textwidget(
 482                parent=self._subcontainer,
 483                position=(self._sub_width * 0.5, v),
 484                size=(0, 0),
 485                scale=1.5,
 486                maxwidth=self._sub_width * 0.9,
 487                res_scale=1.5,
 488                color=(1, 1, 1, 1),
 489                h_align='center',
 490                v_align='center',
 491            )
 492
 493            self._refresh_account_name_text()
 494
 495            v -= signed_in_as_space * 0.4
 496
 497            for via in via_lines:
 498                v -= via_space * 0.1
 499                sscale = 0.7
 500                swidth = (
 501                    bui.get_string_width(via, suppress_warning=True) * sscale
 502                )
 503                bui.textwidget(
 504                    parent=self._subcontainer,
 505                    position=(self._sub_width * 0.5, v),
 506                    size=(0, 0),
 507                    text=via,
 508                    scale=sscale,
 509                    color=(0.6, 0.6, 0.6),
 510                    flatness=1.0,
 511                    shadow=0.0,
 512                    h_align='center',
 513                    v_align='center',
 514                )
 515                bui.textwidget(
 516                    parent=self._subcontainer,
 517                    position=(self._sub_width * 0.5 - swidth * 0.5 - 5, v),
 518                    size=(0, 0),
 519                    text=bui.Lstr(
 520                        value='(${VIA}',
 521                        subs=[('${VIA}', bui.Lstr(resource='viaText'))],
 522                    ),
 523                    scale=0.5,
 524                    color=(0.4, 0.6, 0.4, 0.5),
 525                    flatness=1.0,
 526                    shadow=0.0,
 527                    h_align='right',
 528                    v_align='center',
 529                )
 530                bui.textwidget(
 531                    parent=self._subcontainer,
 532                    position=(self._sub_width * 0.5 + swidth * 0.5 + 10, v),
 533                    size=(0, 0),
 534                    text=')',
 535                    scale=0.5,
 536                    color=(0.4, 0.6, 0.4, 0.5),
 537                    flatness=1.0,
 538                    shadow=0.0,
 539                    h_align='right',
 540                    v_align='center',
 541                )
 542
 543                v -= via_space * 0.9
 544
 545        else:
 546            self._account_name_text = None
 547
 548        if self._back_button is None:
 549            bbtn = bui.get_special_widget('back_button')
 550        else:
 551            bbtn = self._back_button
 552
 553        if show_sign_in_benefits:
 554            v -= sign_in_benefits_space
 555            bui.textwidget(
 556                parent=self._subcontainer,
 557                position=(
 558                    self._sub_width * 0.5,
 559                    v + sign_in_benefits_space * 0.4,
 560                ),
 561                size=(0, 0),
 562                text=bui.Lstr(resource=f'{self._r}.signInInfoText'),
 563                max_height=sign_in_benefits_space * 0.9,
 564                scale=0.9,
 565                color=(0.75, 0.7, 0.8),
 566                maxwidth=self._sub_width * 0.8,
 567                h_align='center',
 568                v_align='center',
 569            )
 570
 571        if show_signing_in_text:
 572            v -= signing_in_text_space
 573
 574            bui.textwidget(
 575                parent=self._subcontainer,
 576                position=(
 577                    self._sub_width * 0.5,
 578                    v + signing_in_text_space * 0.5,
 579                ),
 580                size=(0, 0),
 581                text=bui.Lstr(resource='accountSettingsWindow.signingInText'),
 582                scale=0.9,
 583                color=(0, 1, 0),
 584                maxwidth=self._sub_width * 0.8,
 585                h_align='center',
 586                v_align='center',
 587            )
 588
 589        if show_google_play_sign_in_button:
 590            button_width = 350
 591            v -= sign_in_button_space
 592            self._sign_in_google_play_button = btn = bui.buttonwidget(
 593                parent=self._subcontainer,
 594                position=((self._sub_width - button_width) * 0.5, v - 20),
 595                autoselect=True,
 596                size=(button_width, 60),
 597                label=bui.Lstr(
 598                    value='${A} ${B}',
 599                    subs=[
 600                        (
 601                            '${A}',
 602                            bui.charstr(bui.SpecialChar.GOOGLE_PLAY_GAMES_LOGO),
 603                        ),
 604                        (
 605                            '${B}',
 606                            bui.Lstr(
 607                                resource=f'{self._r}.signInWithText',
 608                                subs=[
 609                                    (
 610                                        '${SERVICE}',
 611                                        bui.Lstr(resource='googlePlayText'),
 612                                    )
 613                                ],
 614                            ),
 615                        ),
 616                    ],
 617                ),
 618                on_activate_call=lambda: self._sign_in_press(LoginType.GPGS),
 619            )
 620            if first_selectable is None:
 621                first_selectable = btn
 622            bui.widget(
 623                edit=btn, right_widget=bui.get_special_widget('squad_button')
 624            )
 625            bui.widget(edit=btn, left_widget=bbtn)
 626            bui.widget(edit=btn, show_buffer_bottom=40, show_buffer_top=100)
 627            self._sign_in_text = None
 628
 629        if show_game_center_sign_in_button:
 630            button_width = 350
 631            v -= sign_in_button_space
 632            self._sign_in_google_play_button = btn = bui.buttonwidget(
 633                parent=self._subcontainer,
 634                position=((self._sub_width - button_width) * 0.5, v - 20),
 635                autoselect=True,
 636                size=(button_width, 60),
 637                # Note: Apparently Game Center is just called 'Game Center'
 638                # in all languages. Can revisit if not true.
 639                # https://developer.apple.com/forums/thread/725779
 640                label=bui.Lstr(
 641                    value='${A} ${B}',
 642                    subs=[
 643                        (
 644                            '${A}',
 645                            bui.charstr(bui.SpecialChar.GAME_CENTER_LOGO),
 646                        ),
 647                        (
 648                            '${B}',
 649                            bui.Lstr(
 650                                resource=f'{self._r}.signInWithText',
 651                                subs=[('${SERVICE}', 'Game Center')],
 652                            ),
 653                        ),
 654                    ],
 655                ),
 656                on_activate_call=lambda: self._sign_in_press(
 657                    LoginType.GAME_CENTER
 658                ),
 659            )
 660            if first_selectable is None:
 661                first_selectable = btn
 662            bui.widget(
 663                edit=btn, right_widget=bui.get_special_widget('squad_button')
 664            )
 665            bui.widget(edit=btn, left_widget=bbtn)
 666            bui.widget(edit=btn, show_buffer_bottom=40, show_buffer_top=100)
 667            self._sign_in_text = None
 668
 669        if show_v2_proxy_sign_in_button:
 670            button_width = 350
 671            v -= sign_in_button_space
 672            self._sign_in_v2_proxy_button = btn = bui.buttonwidget(
 673                parent=self._subcontainer,
 674                position=((self._sub_width - button_width) * 0.5, v - 20),
 675                autoselect=True,
 676                size=(button_width, 60),
 677                label='',
 678                on_activate_call=self._v2_proxy_sign_in_press,
 679            )
 680
 681            v2labeltext: bui.Lstr | str = (
 682                bui.Lstr(resource=f'{self._r}.signInWithAnEmailAddressText')
 683                if show_game_center_sign_in_button
 684                or show_google_play_sign_in_button
 685                or show_device_sign_in_button
 686                else bui.Lstr(resource=f'{self._r}.signInText')
 687            )
 688            v2infotext: bui.Lstr | str | None = None
 689
 690            bui.textwidget(
 691                parent=self._subcontainer,
 692                draw_controller=btn,
 693                h_align='center',
 694                v_align='center',
 695                size=(0, 0),
 696                position=(
 697                    self._sub_width * 0.5,
 698                    v + (17 if v2infotext is not None else 10),
 699                ),
 700                text=bui.Lstr(
 701                    value='${A} ${B}',
 702                    subs=[
 703                        ('${A}', bui.charstr(bui.SpecialChar.V2_LOGO)),
 704                        (
 705                            '${B}',
 706                            v2labeltext,
 707                        ),
 708                    ],
 709                ),
 710                maxwidth=button_width * 0.8,
 711                color=(0.75, 1.0, 0.7),
 712            )
 713            if v2infotext is not None:
 714                bui.textwidget(
 715                    parent=self._subcontainer,
 716                    draw_controller=btn,
 717                    h_align='center',
 718                    v_align='center',
 719                    size=(0, 0),
 720                    position=(self._sub_width * 0.5, v - 4),
 721                    text=v2infotext,
 722                    flatness=1.0,
 723                    scale=0.57,
 724                    maxwidth=button_width * 0.9,
 725                    color=(0.55, 0.8, 0.5),
 726                )
 727            if first_selectable is None:
 728                first_selectable = btn
 729            bui.widget(
 730                edit=btn, right_widget=bui.get_special_widget('squad_button')
 731            )
 732            bui.widget(edit=btn, left_widget=bbtn)
 733            bui.widget(edit=btn, show_buffer_bottom=40, show_buffer_top=100)
 734            self._sign_in_text = None
 735
 736        if show_device_sign_in_button:
 737            button_width = 350
 738            v -= sign_in_button_space + deprecated_space
 739            self._sign_in_device_button = btn = bui.buttonwidget(
 740                parent=self._subcontainer,
 741                position=((self._sub_width - button_width) * 0.5, v - 20),
 742                autoselect=True,
 743                size=(button_width, 60),
 744                label='',
 745                on_activate_call=lambda: self._sign_in_press('Local'),
 746            )
 747            bui.textwidget(
 748                parent=self._subcontainer,
 749                h_align='center',
 750                v_align='center',
 751                size=(0, 0),
 752                position=(self._sub_width * 0.5, v + 60),
 753                text=bui.Lstr(resource='deprecatedText'),
 754                scale=0.8,
 755                maxwidth=300,
 756                color=(0.6, 0.55, 0.45),
 757            )
 758
 759            bui.textwidget(
 760                parent=self._subcontainer,
 761                draw_controller=btn,
 762                h_align='center',
 763                v_align='center',
 764                size=(0, 0),
 765                position=(self._sub_width * 0.5, v + 17),
 766                text=bui.Lstr(
 767                    value='${A} ${B}',
 768                    subs=[
 769                        ('${A}', bui.charstr(bui.SpecialChar.LOCAL_ACCOUNT)),
 770                        (
 771                            '${B}',
 772                            bui.Lstr(
 773                                resource=f'{self._r}.signInWithDeviceText'
 774                            ),
 775                        ),
 776                    ],
 777                ),
 778                maxwidth=button_width * 0.8,
 779                color=(0.75, 1.0, 0.7),
 780            )
 781            bui.textwidget(
 782                parent=self._subcontainer,
 783                draw_controller=btn,
 784                h_align='center',
 785                v_align='center',
 786                size=(0, 0),
 787                position=(self._sub_width * 0.5, v - 4),
 788                text=bui.Lstr(resource=f'{self._r}.signInWithDeviceInfoText'),
 789                flatness=1.0,
 790                scale=0.57,
 791                maxwidth=button_width * 0.9,
 792                color=(0.55, 0.8, 0.5),
 793            )
 794            if first_selectable is None:
 795                first_selectable = btn
 796            bui.widget(
 797                edit=btn, right_widget=bui.get_special_widget('squad_button')
 798            )
 799            bui.widget(edit=btn, left_widget=bbtn)
 800            bui.widget(edit=btn, show_buffer_bottom=40, show_buffer_top=100)
 801            self._sign_in_text = None
 802
 803        if show_manage_account_button:
 804            button_width = 300
 805            v -= manage_account_button_space
 806            self._manage_button = btn = bui.buttonwidget(
 807                parent=self._subcontainer,
 808                position=((self._sub_width - button_width) * 0.5, v),
 809                autoselect=True,
 810                size=(button_width, 60),
 811                label=bui.Lstr(resource=f'{self._r}.manageAccountText'),
 812                color=(0.55, 0.5, 0.6),
 813                icon=bui.gettexture('settingsIcon'),
 814                textcolor=(0.75, 0.7, 0.8),
 815                on_activate_call=bui.WeakCall(self._on_manage_account_press),
 816            )
 817            if first_selectable is None:
 818                first_selectable = btn
 819            bui.widget(
 820                edit=btn, right_widget=bui.get_special_widget('squad_button')
 821            )
 822            bui.widget(edit=btn, left_widget=bbtn)
 823
 824        # the button to go to OS-Specific leaderboards/high-score-lists/etc.
 825        if show_game_service_button:
 826            button_width = 300
 827            v -= game_service_button_space * 0.6
 828            if game_center_active:
 829                # Note: Apparently Game Center is just called 'Game Center'
 830                # in all languages. Can revisit if not true.
 831                # https://developer.apple.com/forums/thread/725779
 832                game_service_button_label = bui.Lstr(
 833                    value=bui.charstr(bui.SpecialChar.GAME_CENTER_LOGO)
 834                    + 'Game Center'
 835                )
 836            else:
 837                raise ValueError(
 838                    "unknown account type: '" + str(v1_account_type) + "'"
 839                )
 840            self._game_service_button = btn = bui.buttonwidget(
 841                parent=self._subcontainer,
 842                position=((self._sub_width - button_width) * 0.5, v),
 843                color=(0.55, 0.5, 0.6),
 844                textcolor=(0.75, 0.7, 0.8),
 845                autoselect=True,
 846                on_activate_call=self._on_game_service_button_press,
 847                size=(button_width, 50),
 848                label=game_service_button_label,
 849            )
 850            if first_selectable is None:
 851                first_selectable = btn
 852            bui.widget(
 853                edit=btn, right_widget=bui.get_special_widget('squad_button')
 854            )
 855            bui.widget(edit=btn, left_widget=bbtn)
 856            v -= game_service_button_space * 0.4
 857        else:
 858            self.game_service_button = None
 859
 860        self._achievements_text: bui.Widget | None
 861        if show_achievements_text:
 862            v -= achievements_text_space * 0.5
 863            self._achievements_text = bui.textwidget(
 864                parent=self._subcontainer,
 865                position=(self._sub_width * 0.5, v),
 866                size=(0, 0),
 867                scale=0.9,
 868                color=(0.75, 0.7, 0.8),
 869                maxwidth=self._sub_width * 0.8,
 870                h_align='center',
 871                v_align='center',
 872            )
 873            v -= achievements_text_space * 0.5
 874        else:
 875            self._achievements_text = None
 876
 877        if show_achievements_text:
 878            self._refresh_achievements()
 879
 880        self._leaderboards_button: bui.Widget | None
 881        if show_leaderboards_button:
 882            button_width = 300
 883            v -= leaderboards_button_space * 0.85
 884            self._leaderboards_button = btn = bui.buttonwidget(
 885                parent=self._subcontainer,
 886                position=((self._sub_width - button_width) * 0.5, v),
 887                color=(0.55, 0.5, 0.6),
 888                textcolor=(0.75, 0.7, 0.8),
 889                autoselect=True,
 890                icon=bui.gettexture('googlePlayLeaderboardsIcon'),
 891                icon_color=(0.8, 0.95, 0.7),
 892                on_activate_call=self._on_leaderboards_press,
 893                size=(button_width, 50),
 894                label=bui.Lstr(resource='leaderboardsText'),
 895            )
 896            if first_selectable is None:
 897                first_selectable = btn
 898            bui.widget(
 899                edit=btn, right_widget=bui.get_special_widget('squad_button')
 900            )
 901            bui.widget(edit=btn, left_widget=bbtn)
 902            v -= leaderboards_button_space * 0.15
 903        else:
 904            self._leaderboards_button = None
 905
 906        self._campaign_progress_text: bui.Widget | None
 907        if show_campaign_progress:
 908            v -= campaign_progress_space * 0.5
 909            self._campaign_progress_text = bui.textwidget(
 910                parent=self._subcontainer,
 911                position=(self._sub_width * 0.5, v),
 912                size=(0, 0),
 913                scale=0.9,
 914                color=(0.75, 0.7, 0.8),
 915                maxwidth=self._sub_width * 0.8,
 916                h_align='center',
 917                v_align='center',
 918            )
 919            v -= campaign_progress_space * 0.5
 920            self._refresh_campaign_progress_text()
 921        else:
 922            self._campaign_progress_text = None
 923
 924        self._tickets_text: bui.Widget | None
 925        if show_tickets:
 926            v -= tickets_space * 0.5
 927            self._tickets_text = bui.textwidget(
 928                parent=self._subcontainer,
 929                position=(self._sub_width * 0.5, v),
 930                size=(0, 0),
 931                scale=0.9,
 932                color=(0.75, 0.7, 0.8),
 933                maxwidth=self._sub_width * 0.8,
 934                flatness=1.0,
 935                h_align='center',
 936                v_align='center',
 937            )
 938            v -= tickets_space * 0.5
 939            self._refresh_tickets_text()
 940
 941        else:
 942            self._tickets_text = None
 943
 944        # bit of spacing before the reset/sign-out section
 945        # v -= 5
 946
 947        button_width = 300
 948
 949        self._linked_accounts_text: bui.Widget | None
 950        if show_linked_accounts_text:
 951            v -= linked_accounts_text_space * 0.8
 952            self._linked_accounts_text = bui.textwidget(
 953                parent=self._subcontainer,
 954                position=(self._sub_width * 0.5, v),
 955                size=(0, 0),
 956                scale=0.9,
 957                color=(0.75, 0.7, 0.8),
 958                maxwidth=self._sub_width * 0.95,
 959                text=bui.Lstr(resource=f'{self._r}.linkedAccountsText'),
 960                h_align='center',
 961                v_align='center',
 962            )
 963            v -= linked_accounts_text_space * 0.2
 964            self._update_linked_accounts_text()
 965        else:
 966            self._linked_accounts_text = None
 967
 968        # Show link/unlink buttons only for V1 accounts.
 969
 970        if show_link_accounts_button:
 971            v -= link_accounts_button_space
 972            self._link_accounts_button = btn = bui.buttonwidget(
 973                parent=self._subcontainer,
 974                position=((self._sub_width - button_width) * 0.5, v),
 975                autoselect=True,
 976                size=(button_width, 60),
 977                label='',
 978                color=(0.55, 0.5, 0.6),
 979                on_activate_call=self._link_accounts_press,
 980            )
 981            bui.textwidget(
 982                parent=self._subcontainer,
 983                draw_controller=btn,
 984                h_align='center',
 985                v_align='center',
 986                size=(0, 0),
 987                position=(self._sub_width * 0.5, v + 17 + 20),
 988                text=bui.Lstr(resource=f'{self._r}.linkAccountsText'),
 989                maxwidth=button_width * 0.8,
 990                color=(0.75, 0.7, 0.8),
 991            )
 992            bui.textwidget(
 993                parent=self._subcontainer,
 994                draw_controller=btn,
 995                h_align='center',
 996                v_align='center',
 997                size=(0, 0),
 998                position=(self._sub_width * 0.5, v - 4 + 20),
 999                text=bui.Lstr(resource=f'{self._r}.linkAccountsInfoText'),
1000                flatness=1.0,
1001                scale=0.5,
1002                maxwidth=button_width * 0.8,
1003                color=(0.75, 0.7, 0.8),
1004            )
1005            if first_selectable is None:
1006                first_selectable = btn
1007            bui.widget(
1008                edit=btn, right_widget=bui.get_special_widget('squad_button')
1009            )
1010            bui.widget(edit=btn, left_widget=bbtn, show_buffer_bottom=50)
1011
1012        self._unlink_accounts_button: bui.Widget | None
1013        if show_unlink_accounts_button:
1014            v -= unlink_accounts_button_space
1015            self._unlink_accounts_button = btn = bui.buttonwidget(
1016                parent=self._subcontainer,
1017                position=((self._sub_width - button_width) * 0.5, v + 25),
1018                autoselect=True,
1019                size=(button_width, 60),
1020                label='',
1021                color=(0.55, 0.5, 0.6),
1022                on_activate_call=self._unlink_accounts_press,
1023            )
1024            self._unlink_accounts_button_label = bui.textwidget(
1025                parent=self._subcontainer,
1026                draw_controller=btn,
1027                h_align='center',
1028                v_align='center',
1029                size=(0, 0),
1030                position=(self._sub_width * 0.5, v + 55),
1031                text=bui.Lstr(resource=f'{self._r}.unlinkAccountsText'),
1032                maxwidth=button_width * 0.8,
1033                color=(0.75, 0.7, 0.8),
1034            )
1035            if first_selectable is None:
1036                first_selectable = btn
1037            bui.widget(
1038                edit=btn, right_widget=bui.get_special_widget('squad_button')
1039            )
1040            bui.widget(edit=btn, left_widget=bbtn, show_buffer_bottom=50)
1041            self._update_unlink_accounts_button()
1042        else:
1043            self._unlink_accounts_button = None
1044
1045        if show_v2_link_info:
1046            v -= v2_link_info_space
1047            bui.textwidget(
1048                parent=self._subcontainer,
1049                h_align='center',
1050                v_align='center',
1051                size=(0, 0),
1052                position=(self._sub_width * 0.5, v + v2_link_info_space - 20),
1053                text=bui.Lstr(resource='v2AccountLinkingInfoText'),
1054                flatness=1.0,
1055                scale=0.8,
1056                maxwidth=450,
1057                color=(0.5, 0.45, 0.55),
1058            )
1059
1060        if self._show_legacy_unlink_button:
1061            v -= legacy_unlink_button_space
1062            button_width_w = button_width * 1.5
1063            bui.textwidget(
1064                parent=self._subcontainer,
1065                position=(self._sub_width * 0.5 - 150.0, v + 75),
1066                size=(300.0, 60),
1067                text=bui.Lstr(resource='whatIsThisText'),
1068                scale=0.8,
1069                color=(0.3, 0.7, 0.05),
1070                maxwidth=200.0,
1071                h_align='center',
1072                v_align='center',
1073                autoselect=True,
1074                selectable=True,
1075                on_activate_call=show_what_is_legacy_unlinking_page,
1076                click_activate=True,
1077            )
1078            btn = bui.buttonwidget(
1079                parent=self._subcontainer,
1080                position=((self._sub_width - button_width_w) * 0.5, v + 25),
1081                autoselect=True,
1082                size=(button_width_w, 60),
1083                label=bui.Lstr(
1084                    resource=f'{self._r}.unlinkLegacyV1AccountsText'
1085                ),
1086                textcolor=(0.8, 0.4, 0),
1087                color=(0.55, 0.5, 0.6),
1088                on_activate_call=self._unlink_accounts_press,
1089            )
1090
1091        if show_sign_out_button:
1092            v -= sign_out_button_space
1093            self._sign_out_button = btn = bui.buttonwidget(
1094                parent=self._subcontainer,
1095                position=((self._sub_width - button_width) * 0.5, v),
1096                size=(button_width, 60),
1097                label=bui.Lstr(resource=f'{self._r}.signOutText'),
1098                color=(0.55, 0.5, 0.6),
1099                textcolor=(0.75, 0.7, 0.8),
1100                autoselect=True,
1101                on_activate_call=self._sign_out_press,
1102            )
1103            if first_selectable is None:
1104                first_selectable = btn
1105            bui.widget(
1106                edit=btn, right_widget=bui.get_special_widget('squad_button')
1107            )
1108            bui.widget(edit=btn, left_widget=bbtn, show_buffer_bottom=15)
1109
1110        if show_cancel_sign_in_button:
1111            v -= cancel_sign_in_button_space
1112            self._cancel_sign_in_button = btn = bui.buttonwidget(
1113                parent=self._subcontainer,
1114                position=((self._sub_width - button_width) * 0.5, v),
1115                size=(button_width, 60),
1116                label=bui.Lstr(resource='cancelText'),
1117                color=(0.55, 0.5, 0.6),
1118                textcolor=(0.75, 0.7, 0.8),
1119                autoselect=True,
1120                on_activate_call=self._cancel_sign_in_press,
1121            )
1122            if first_selectable is None:
1123                first_selectable = btn
1124            bui.widget(
1125                edit=btn, right_widget=bui.get_special_widget('squad_button')
1126            )
1127            bui.widget(edit=btn, left_widget=bbtn, show_buffer_bottom=15)
1128
1129        if show_delete_account_button:
1130            v -= delete_account_button_space
1131            self._delete_account_button = btn = bui.buttonwidget(
1132                parent=self._subcontainer,
1133                position=((self._sub_width - button_width) * 0.5, v),
1134                size=(button_width, 60),
1135                label=bui.Lstr(resource=f'{self._r}.deleteAccountText'),
1136                color=(0.85, 0.5, 0.6),
1137                textcolor=(0.9, 0.7, 0.8),
1138                autoselect=True,
1139                on_activate_call=self._on_delete_account_press,
1140            )
1141            if first_selectable is None:
1142                first_selectable = btn
1143            bui.widget(
1144                edit=btn, right_widget=bui.get_special_widget('squad_button')
1145            )
1146            bui.widget(edit=btn, left_widget=bbtn, show_buffer_bottom=15)
1147
1148        # Whatever the topmost selectable thing is, we want it to scroll all
1149        # the way up when we select it.
1150        if first_selectable is not None:
1151            bui.widget(
1152                edit=first_selectable, up_widget=bbtn, show_buffer_top=400
1153            )
1154            # (this should re-scroll us to the top..)
1155            bui.containerwidget(
1156                edit=self._subcontainer, visible_child=first_selectable
1157            )
1158        self._needs_refresh = False
1159
1160    def _on_game_service_button_press(self) -> None:
1161        if bui.app.plus is not None:
1162            bui.app.plus.show_game_service_ui()
1163        else:
1164            logging.warning(
1165                'game-service-ui not available without plus feature-set.'
1166            )
1167
1168    def _on_custom_achievements_press(self) -> None:
1169        if bui.app.plus is not None:
1170            bui.apptimer(
1171                0.15,
1172                bui.Call(bui.app.plus.show_game_service_ui, 'achievements'),
1173            )
1174        else:
1175            logging.warning('show_game_service_ui requires plus feature-set.')
1176
1177    def _on_manage_account_press(self) -> None:
1178        self._do_manage_account_press(WebLocation.ACCOUNT_EDITOR)
1179
1180    def _on_delete_account_press(self) -> None:
1181        self._do_manage_account_press(WebLocation.ACCOUNT_DELETE_SECTION)
1182
1183    def _do_manage_account_press(self, weblocation: WebLocation) -> None:
1184        plus = bui.app.plus
1185        assert plus is not None
1186
1187        # Preemptively fail if it looks like we won't be able to talk to
1188        # the server anyway.
1189        if not plus.cloud.connected:
1190            bui.screenmessage(
1191                bui.Lstr(resource='internal.unavailableNoConnectionText'),
1192                color=(1, 0, 0),
1193            )
1194            bui.getsound('error').play()
1195            return
1196
1197        bui.screenmessage(bui.Lstr(resource='oneMomentText'))
1198
1199        # We expect to have a v2 account signed in if we get here.
1200        if plus.accounts.primary is None:
1201            logging.exception(
1202                'got manage-account press without v2 account present'
1203            )
1204            return
1205
1206        with plus.accounts.primary:
1207            plus.cloud.send_message_cb(
1208                bacommon.cloud.ManageAccountMessage(weblocation=weblocation),
1209                on_response=bui.WeakCall(self._on_manage_account_response),
1210            )
1211
1212    def _on_manage_account_response(
1213        self, response: bacommon.cloud.ManageAccountResponse | Exception
1214    ) -> None:
1215        if isinstance(response, Exception) or response.url is None:
1216            logging.warning(
1217                'Got error in manage-account-response: %s.', response
1218            )
1219            bui.screenmessage(bui.Lstr(resource='errorText'), color=(1, 0, 0))
1220            bui.getsound('error').play()
1221            return
1222
1223        bui.open_url(response.url)
1224
1225    def _on_leaderboards_press(self) -> None:
1226        if bui.app.plus is not None:
1227            bui.apptimer(
1228                0.15,
1229                bui.Call(bui.app.plus.show_game_service_ui, 'leaderboards'),
1230            )
1231        else:
1232            logging.warning('show_game_service_ui requires classic')
1233
1234    def _have_unlinkable_v1_accounts(self) -> bool:
1235        plus = bui.app.plus
1236        assert plus is not None
1237
1238        # if this is not present, we haven't had contact from the server so
1239        # let's not proceed..
1240        if plus.get_v1_account_public_login_id() is None:
1241            return False
1242        accounts = plus.get_v1_account_misc_read_val_2('linkedAccounts', [])
1243        return len(accounts) > 1
1244
1245    def _update_unlink_accounts_button(self) -> None:
1246        if self._unlink_accounts_button is None:
1247            return
1248        if self._have_unlinkable_v1_accounts():
1249            clr = (0.75, 0.7, 0.8, 1.0)
1250        else:
1251            clr = (1.0, 1.0, 1.0, 0.25)
1252        bui.textwidget(edit=self._unlink_accounts_button_label, color=clr)
1253
1254    def _should_show_legacy_unlink_button(self) -> bool:
1255        plus = bui.app.plus
1256        assert plus is not None
1257
1258        # Only show this when fully signed in to a v2 account.
1259        if not self._v1_signed_in or plus.accounts.primary is None:
1260            return False
1261
1262        out = self._have_unlinkable_v1_accounts()
1263        return out
1264
1265    def _update_linked_accounts_text(self) -> None:
1266        plus = bui.app.plus
1267        assert plus is not None
1268
1269        if self._linked_accounts_text is None:
1270            return
1271
1272        # Disable this by default when signed in to a V2 account
1273        # (since this shows V1 links which we should no longer care about).
1274        if plus.accounts.primary is not None and not FORCE_ENABLE_V1_LINKING:
1275            return
1276
1277        # if this is not present, we haven't had contact from the server so
1278        # let's not proceed..
1279        if plus.get_v1_account_public_login_id() is None:
1280            num = int(time.time()) % 4
1281            accounts_str = num * '.' + (4 - num) * ' '
1282        else:
1283            accounts = plus.get_v1_account_misc_read_val_2('linkedAccounts', [])
1284            # UPDATE - we now just print the number here; not the actual
1285            # accounts (they can see that in the unlink section if they're
1286            # curious)
1287            accounts_str = str(max(0, len(accounts) - 1))
1288        bui.textwidget(
1289            edit=self._linked_accounts_text,
1290            text=bui.Lstr(
1291                value='${L} ${A}',
1292                subs=[
1293                    (
1294                        '${L}',
1295                        bui.Lstr(resource=f'{self._r}.linkedAccountsText'),
1296                    ),
1297                    ('${A}', accounts_str),
1298                ],
1299            ),
1300        )
1301
1302    def _refresh_campaign_progress_text(self) -> None:
1303        if self._campaign_progress_text is None:
1304            return
1305        p_str: str | bui.Lstr
1306        try:
1307            assert bui.app.classic is not None
1308            campaign = bui.app.classic.getcampaign('Default')
1309            levels = campaign.levels
1310            levels_complete = sum((1 if l.complete else 0) for l in levels)
1311
1312            # Last level cant be completed; hence the -1;
1313            progress = min(1.0, float(levels_complete) / (len(levels) - 1))
1314            p_str = bui.Lstr(
1315                resource=f'{self._r}.campaignProgressText',
1316                subs=[('${PROGRESS}', str(int(progress * 100.0)) + '%')],
1317            )
1318        except Exception:
1319            p_str = '?'
1320            logging.exception('Error calculating co-op campaign progress.')
1321        bui.textwidget(edit=self._campaign_progress_text, text=p_str)
1322
1323    def _refresh_tickets_text(self) -> None:
1324        plus = bui.app.plus
1325        assert plus is not None
1326
1327        if self._tickets_text is None:
1328            return
1329        try:
1330            tc_str = str(plus.get_v1_account_ticket_count())
1331        except Exception:
1332            logging.exception('error refreshing tickets text')
1333            tc_str = '-'
1334        bui.textwidget(
1335            edit=self._tickets_text,
1336            text=bui.Lstr(
1337                resource=f'{self._r}.ticketsText', subs=[('${COUNT}', tc_str)]
1338            ),
1339        )
1340
1341    def _refresh_account_name_text(self) -> None:
1342        plus = bui.app.plus
1343        assert plus is not None
1344
1345        if self._account_name_text is None:
1346            return
1347        try:
1348            name_str = plus.get_v1_account_display_string()
1349        except Exception:
1350            logging.exception('error refreshing tickets text')
1351            name_str = '??'
1352
1353        bui.textwidget(edit=self._account_name_text, text=name_str)
1354
1355    def _refresh_achievements(self) -> None:
1356        assert bui.app.classic is not None
1357        if self._achievements_text is None:
1358            return
1359        complete = sum(
1360            1 if a.complete else 0 for a in bui.app.classic.ach.achievements
1361        )
1362        total = len(bui.app.classic.ach.achievements)
1363        txt_final = bui.Lstr(
1364            resource=f'{self._r}.achievementProgressText',
1365            subs=[('${COUNT}', str(complete)), ('${TOTAL}', str(total))],
1366        )
1367
1368        if self._achievements_text is not None:
1369            bui.textwidget(edit=self._achievements_text, text=txt_final)
1370
1371    def _link_accounts_press(self) -> None:
1372        # pylint: disable=cyclic-import
1373        from bauiv1lib.account.link import AccountLinkWindow
1374
1375        AccountLinkWindow(origin_widget=self._link_accounts_button)
1376
1377    def _unlink_accounts_press(self) -> None:
1378        # pylint: disable=cyclic-import
1379        from bauiv1lib.account.unlink import AccountUnlinkWindow
1380
1381        if not self._have_unlinkable_v1_accounts():
1382            bui.getsound('error').play()
1383            return
1384
1385        AccountUnlinkWindow(origin_widget=self._unlink_accounts_button)
1386
1387    def _cancel_sign_in_press(self) -> None:
1388        # If we're waiting on an adapter to give us credentials, abort.
1389        self._signing_in_adapter = None
1390
1391        plus = bui.app.plus
1392        assert plus is not None
1393
1394        # Say we don't wanna be signed in anymore if we are.
1395        plus.accounts.set_primary_credentials(None)
1396
1397        self._needs_refresh = True
1398
1399        # Speed UI updates along.
1400        bui.apptimer(0.1, bui.WeakCall(self._update))
1401
1402    def _sign_out_press(self) -> None:
1403        plus = bui.app.plus
1404        assert plus is not None
1405
1406        if plus.accounts.have_primary_credentials():
1407            if (
1408                plus.accounts.primary is not None
1409                and LoginType.GPGS in plus.accounts.primary.logins
1410            ):
1411                self._explicitly_signed_out_of_gpgs = True
1412            plus.accounts.set_primary_credentials(None)
1413        else:
1414            plus.sign_out_v1()
1415
1416        cfg = bui.app.config
1417
1418        # Also take note that its our *explicit* intention to not be
1419        # signed in at this point (affects v1 accounts).
1420        cfg['Auto Account State'] = 'signed_out'
1421        cfg.commit()
1422        bui.buttonwidget(
1423            edit=self._sign_out_button,
1424            label=bui.Lstr(resource=f'{self._r}.signingOutText'),
1425        )
1426
1427        # Speed UI updates along.
1428        bui.apptimer(0.1, bui.WeakCall(self._update))
1429
1430    def _sign_in_press(self, login_type: str | LoginType) -> None:
1431        from bauiv1lib.connectivity import wait_for_connectivity
1432
1433        # If we're still waiting for our master-server connection,
1434        # keep the user informed of this instead of rushing in and
1435        # failing immediately.
1436        wait_for_connectivity(on_connected=lambda: self._sign_in(login_type))
1437
1438    def _sign_in(self, login_type: str | LoginType) -> None:
1439        plus = bui.app.plus
1440        assert plus is not None
1441
1442        # V1 login types are strings.
1443        if isinstance(login_type, str):
1444            plus.sign_in_v1(login_type)
1445
1446            # Make note of the type account we're *wanting*
1447            # to be signed in with.
1448            cfg = bui.app.config
1449            cfg['Auto Account State'] = login_type
1450            cfg.commit()
1451            self._needs_refresh = True
1452            bui.apptimer(0.1, bui.WeakCall(self._update))
1453            return
1454
1455        # V2 login sign-in buttons generally go through adapters.
1456        adapter = plus.accounts.login_adapters.get(login_type)
1457        if adapter is not None:
1458            self._signing_in_adapter = adapter
1459            adapter.sign_in(
1460                result_cb=bui.WeakCall(self._on_adapter_sign_in_result),
1461                description='account settings button',
1462            )
1463            # Will get 'Signing in...' to show.
1464            self._needs_refresh = True
1465            bui.apptimer(0.1, bui.WeakCall(self._update))
1466        else:
1467            bui.screenmessage(f'Unsupported login_type: {login_type.name}')
1468
1469    def _on_adapter_sign_in_result(
1470        self,
1471        adapter: bui.LoginAdapter,
1472        result: bui.LoginAdapter.SignInResult | Exception,
1473    ) -> None:
1474        is_us = self._signing_in_adapter is adapter
1475
1476        # If this isn't our current one we don't care.
1477        if not is_us:
1478            return
1479
1480        # If it is us, note that we're done.
1481        self._signing_in_adapter = None
1482
1483        if isinstance(result, Exception):
1484            # For now just make a bit of noise if anything went wrong;
1485            # can get more specific as needed later.
1486            logging.warning('Got error in v2 sign-in result: %s', result)
1487            bui.screenmessage(
1488                bui.Lstr(resource='internal.signInNoConnectionText'),
1489                color=(1, 0, 0),
1490            )
1491            bui.getsound('error').play()
1492        else:
1493            # Success! Plug in these credentials which will begin
1494            # verifying them and set our primary account-handle when
1495            # finished.
1496            plus = bui.app.plus
1497            assert plus is not None
1498            plus.accounts.set_primary_credentials(result.credentials)
1499
1500            # Special case - if the user has explicitly signed out and
1501            # signed in again with GPGS via this button, warn them that
1502            # they need to use the app if they want to switch to a
1503            # different GPGS account.
1504            if (
1505                self._explicitly_signed_out_of_gpgs
1506                and adapter.login_type is LoginType.GPGS
1507            ):
1508                # Delay this slightly so it hopefully pops up after
1509                # credentials go through and the account name shows up.
1510                bui.apptimer(
1511                    1.5,
1512                    bui.Call(
1513                        bui.screenmessage,
1514                        bui.Lstr(
1515                            resource=self._r
1516                            + '.googlePlayGamesAccountSwitchText'
1517                        ),
1518                    ),
1519                )
1520
1521        # Speed any UI updates along.
1522        self._needs_refresh = True
1523        bui.apptimer(0.1, bui.WeakCall(self._update))
1524
1525    def _v2_proxy_sign_in_press(self) -> None:
1526        # pylint: disable=cyclic-import
1527        from bauiv1lib.connectivity import wait_for_connectivity
1528
1529        # If we're still waiting for our master-server connection, keep
1530        # the user informed of this instead of rushing in and failing
1531        # immediately.
1532        wait_for_connectivity(on_connected=self._v2_proxy_sign_in)
1533
1534    def _v2_proxy_sign_in(self) -> None:
1535        # pylint: disable=cyclic-import
1536        from bauiv1lib.account.v2proxy import V2ProxySignInWindow
1537
1538        assert self._sign_in_v2_proxy_button is not None
1539        V2ProxySignInWindow(origin_widget=self._sign_in_v2_proxy_button)
1540
1541    def _save_state(self) -> None:
1542        try:
1543            sel = self._root_widget.get_selected_child()
1544            if sel == self._back_button:
1545                sel_name = 'Back'
1546            elif sel == self._scrollwidget:
1547                sel_name = 'Scroll'
1548            else:
1549                raise ValueError('unrecognized selection')
1550            assert bui.app.classic is not None
1551            bui.app.ui_v1.window_states[type(self)] = sel_name
1552        except Exception:
1553            logging.exception('Error saving state for %s.', self)
1554
1555    def _restore_state(self) -> None:
1556        try:
1557            assert bui.app.classic is not None
1558            sel_name = bui.app.ui_v1.window_states.get(type(self))
1559            if sel_name == 'Back':
1560                sel = self._back_button
1561            elif sel_name == 'Scroll':
1562                sel = self._scrollwidget
1563            else:
1564                sel = self._back_button
1565            bui.containerwidget(edit=self._root_widget, selected_child=sel)
1566        except Exception:
1567            logging.exception('Error restoring state for %s.', self)

Window for account related functionality.

AccountSettingsWindow( transition: str | None = 'in_right', modal: bool = False, origin_widget: _bauiv1.Widget | None = None, close_once_signed_in: bool = False)
 28    def __init__(
 29        self,
 30        transition: str | None = 'in_right',
 31        modal: bool = False,
 32        origin_widget: bui.Widget | None = None,
 33        close_once_signed_in: bool = False,
 34    ):
 35        # pylint: disable=too-many-statements
 36
 37        plus = bui.app.plus
 38        assert plus is not None
 39
 40        self._sign_in_v2_proxy_button: bui.Widget | None = None
 41        self._sign_in_device_button: bui.Widget | None = None
 42
 43        self._show_legacy_unlink_button = False
 44
 45        self._signing_in_adapter: bui.LoginAdapter | None = None
 46        self._close_once_signed_in = close_once_signed_in
 47        bui.set_analytics_screen('Account Window')
 48
 49        self._explicitly_signed_out_of_gpgs = False
 50
 51        self._r = 'accountSettingsWindow'
 52        self._modal = modal
 53        self._needs_refresh = False
 54        self._v1_signed_in = plus.get_v1_account_state() == 'signed_in'
 55        self._v1_account_state_num = plus.get_v1_account_state_num()
 56        self._check_sign_in_timer = bui.AppTimer(
 57            1.0, bui.WeakCall(self._update), repeat=True
 58        )
 59
 60        self._can_reset_achievements = False
 61
 62        app = bui.app
 63        assert app.classic is not None
 64        uiscale = app.ui_v1.uiscale
 65
 66        self._width = 850 if uiscale is bui.UIScale.SMALL else 660
 67        x_offs = 70 if uiscale is bui.UIScale.SMALL else 0
 68        self._height = (
 69            380
 70            if uiscale is bui.UIScale.SMALL
 71            else 430 if uiscale is bui.UIScale.MEDIUM else 490
 72        )
 73
 74        self._sign_in_button = None
 75        self._sign_in_text = None
 76
 77        self._scroll_width = self._width - (100 + x_offs * 2)
 78        self._scroll_height = self._height - 120
 79        self._sub_width = self._scroll_width - 20
 80
 81        # Determine which sign-in/sign-out buttons we should show.
 82        self._show_sign_in_buttons: list[str] = []
 83
 84        if LoginType.GPGS in plus.accounts.login_adapters:
 85            self._show_sign_in_buttons.append('Google Play')
 86
 87        if LoginType.GAME_CENTER in plus.accounts.login_adapters:
 88            self._show_sign_in_buttons.append('Game Center')
 89
 90        # Always want to show our web-based v2 login option.
 91        self._show_sign_in_buttons.append('V2Proxy')
 92
 93        # Legacy v1 device accounts available only if the user
 94        # has explicitly enabled deprecated login types.
 95        if bui.app.config.resolve('Show Deprecated Login Types'):
 96            self._show_sign_in_buttons.append('Device')
 97
 98        top_extra = 15 if uiscale is bui.UIScale.SMALL else 0
 99        super().__init__(
100            root_widget=bui.containerwidget(
101                size=(self._width, self._height + top_extra),
102                toolbar_visibility=(
103                    'menu_minimal'
104                    if uiscale is bui.UIScale.SMALL
105                    else 'menu_full'
106                ),
107                scale=(
108                    2.07
109                    if uiscale is bui.UIScale.SMALL
110                    else 1.4 if uiscale is bui.UIScale.MEDIUM else 1.0
111                ),
112                stack_offset=(
113                    (0, 8) if uiscale is bui.UIScale.SMALL else (0, 0)
114                ),
115            ),
116            transition=transition,
117            origin_widget=origin_widget,
118        )
119        if uiscale is bui.UIScale.SMALL:
120            self._back_button = None
121            bui.containerwidget(
122                edit=self._root_widget, on_cancel_call=self.main_window_back
123            )
124        else:
125            self._back_button = btn = bui.buttonwidget(
126                parent=self._root_widget,
127                position=(51 + x_offs, self._height - 62),
128                size=(120, 60),
129                scale=0.8,
130                text_scale=1.2,
131                autoselect=True,
132                label=bui.Lstr(
133                    resource='cancelText' if self._modal else 'backText'
134                ),
135                button_type='regular' if self._modal else 'back',
136                on_activate_call=(
137                    self._modal_close if self._modal else self.main_window_back
138                ),
139            )
140            bui.containerwidget(edit=self._root_widget, cancel_button=btn)
141            if not self._modal:
142                bui.buttonwidget(
143                    edit=btn,
144                    button_type='backSmall',
145                    size=(60, 56),
146                    label=bui.charstr(bui.SpecialChar.BACK),
147                )
148
149        titleyoffs = -12 if uiscale is bui.UIScale.SMALL else 0
150        titlescale = 0.6 if uiscale is bui.UIScale.SMALL else 1.0
151        bui.textwidget(
152            parent=self._root_widget,
153            position=(self._width * 0.5, self._height - 41 + titleyoffs),
154            size=(0, 0),
155            text=bui.Lstr(resource=f'{self._r}.titleText'),
156            color=app.ui_v1.title_color,
157            scale=titlescale,
158            maxwidth=self._width - 340,
159            h_align='center',
160            v_align='center',
161        )
162
163        self._scrollwidget = bui.scrollwidget(
164            parent=self._root_widget,
165            highlight=False,
166            position=(
167                (self._width - self._scroll_width) * 0.5,
168                self._height - 65 - self._scroll_height,
169            ),
170            size=(self._scroll_width, self._scroll_height),
171            claims_left_right=True,
172            claims_tab=True,
173            selection_loops_to_parent=True,
174        )
175        self._subcontainer: bui.Widget | None = None
176        self._refresh()
177        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.

@override
def get_main_window_state(self) -> bauiv1.MainWindowState:
191    @override
192    def get_main_window_state(self) -> bui.MainWindowState:
193        # Support recreating our window for back/refresh purposes.
194        cls = type(self)
195        return bui.BasicMainWindowState(
196            create_call=lambda transition, origin_widget: cls(
197                transition=transition, origin_widget=origin_widget
198            )
199        )

Return a WindowState to recreate this window, if supported.

@override
def on_main_window_close(self) -> None:
201    @override
202    def on_main_window_close(self) -> None:
203        self._save_state()

Called before transitioning out a main window.

A good opportunity to save window state/etc.

def show_what_is_legacy_unlinking_page() -> None:
1570def show_what_is_legacy_unlinking_page() -> None:
1571    """Show the webpage describing legacy unlinking."""
1572    plus = bui.app.plus
1573    assert plus is not None
1574
1575    bamasteraddr = plus.get_master_server_address(version=2)
1576    bui.open_url(f'{bamasteraddr}/whatarev1links')

Show the webpage describing legacy unlinking.