bauiv1lib.specialoffer

UI for presenting sales/etc.

  1# Released under the MIT License. See LICENSE for details.
  2#
  3"""UI for presenting sales/etc."""
  4
  5from __future__ import annotations
  6
  7import copy
  8import logging
  9from typing import TYPE_CHECKING
 10
 11import bauiv1 as bui
 12
 13if TYPE_CHECKING:
 14    from typing import Any
 15
 16
 17class SpecialOfferWindow(bui.Window):
 18    """Window for presenting sales/etc."""
 19
 20    def __init__(self, offer: dict[str, Any], transition: str = 'in_right'):
 21        # pylint: disable=too-many-statements
 22        # pylint: disable=too-many-branches
 23        # pylint: disable=too-many-locals
 24        from babase import SpecialChar
 25        from bauiv1lib.store import item as storeitemui
 26
 27        plus = bui.app.plus
 28        assert plus is not None
 29
 30        assert bui.app.classic is not None
 31        store = bui.app.classic.store
 32
 33        self._cancel_delay = offer.get('cancelDelay', 0)
 34
 35        # First thing: if we're offering pro or an IAP, see if we have a
 36        # price for it.
 37        # If not, abort and go into zombie mode (the user should never see
 38        # us that way).
 39
 40        real_price: str | None
 41
 42        # Misnomer: 'pro' actually means offer 'pro_sale'.
 43        if offer['item'] in ['pro', 'pro_fullprice']:
 44            real_price = plus.get_price(
 45                'pro' if offer['item'] == 'pro_fullprice' else 'pro_sale'
 46            )
 47            if real_price is None and bui.app.env.debug:
 48                print('NOTE: Faking prices for debug build.')
 49                real_price = '$1.23'
 50            zombie = real_price is None
 51        elif isinstance(offer['price'], str):
 52            # (a string price implies IAP id)
 53            real_price = plus.get_price(offer['price'])
 54            if real_price is None and bui.app.env.debug:
 55                print('NOTE: Faking price for debug build.')
 56                real_price = '$1.23'
 57            zombie = real_price is None
 58        else:
 59            real_price = None
 60            zombie = False
 61        if real_price is None:
 62            real_price = '?'
 63
 64        if offer['item'] in ['pro', 'pro_fullprice']:
 65            self._offer_item = 'pro'
 66        else:
 67            self._offer_item = offer['item']
 68
 69        # If we wanted a real price but didn't find one, go zombie.
 70        if zombie:
 71            return
 72
 73        # This can pop up suddenly, so lets block input for 1 second.
 74        bui.lock_all_input()
 75        bui.apptimer(1.0, bui.unlock_all_input)
 76        bui.getsound('ding').play()
 77        bui.apptimer(0.3, bui.getsound('ooh').play)
 78        self._offer = copy.deepcopy(offer)
 79        self._width = 580
 80        self._height = 590
 81        uiscale = bui.app.ui_v1.uiscale
 82        super().__init__(
 83            root_widget=bui.containerwidget(
 84                size=(self._width, self._height),
 85                transition=transition,
 86                scale=(
 87                    1.2
 88                    if uiscale is bui.UIScale.SMALL
 89                    else 1.15 if uiscale is bui.UIScale.MEDIUM else 1.0
 90                ),
 91                stack_offset=(
 92                    (0, -15) if uiscale is bui.UIScale.SMALL else (0, 0)
 93                ),
 94            )
 95        )
 96        self._is_bundle_sale = False
 97        try:
 98            if offer['item'] in ['pro', 'pro_fullprice']:
 99                original_price_str = plus.get_price('pro')
100                if original_price_str is None:
101                    original_price_str = '?'
102                new_price_str = plus.get_price('pro_sale')
103                if new_price_str is None:
104                    new_price_str = '?'
105                percent_off_text = ''
106            else:
107                # If the offer includes bonus tickets, it's a bundle-sale.
108                if (
109                    'bonusTickets' in offer
110                    and offer['bonusTickets'] is not None
111                ):
112                    self._is_bundle_sale = True
113                original_price = plus.get_v1_account_misc_read_val(
114                    'price.' + self._offer_item, 9999
115                )
116
117                # For pure ticket prices we can show a percent-off.
118                if isinstance(offer['price'], int):
119                    new_price = offer['price']
120                    tchar = bui.charstr(SpecialChar.TICKET)
121                    original_price_str = tchar + str(original_price)
122                    new_price_str = tchar + str(new_price)
123                    percent_off = int(
124                        round(
125                            100.0 - (float(new_price) / original_price) * 100.0
126                        )
127                    )
128                    percent_off_text = ' ' + bui.Lstr(
129                        resource='store.salePercentText'
130                    ).evaluate().replace('${PERCENT}', str(percent_off))
131                else:
132                    original_price_str = new_price_str = '?'
133                    percent_off_text = ''
134
135        except Exception:
136            logging.exception('Error setting up special-offer: %s.', offer)
137            original_price_str = new_price_str = '?'
138            percent_off_text = ''
139
140        # If its a bundle sale, change the title.
141        if self._is_bundle_sale:
142            sale_text = bui.Lstr(
143                resource='store.saleBundleText',
144                fallback_resource='store.saleText',
145            ).evaluate()
146        else:
147            # For full pro we say 'Upgrade?' since its not really a sale.
148            if offer['item'] == 'pro_fullprice':
149                sale_text = bui.Lstr(
150                    resource='store.upgradeQuestionText',
151                    fallback_resource='store.saleExclaimText',
152                ).evaluate()
153            else:
154                sale_text = bui.Lstr(
155                    resource='store.saleExclaimText',
156                    fallback_resource='store.saleText',
157                ).evaluate()
158
159        self._title_text = bui.textwidget(
160            parent=self._root_widget,
161            position=(self._width * 0.5, self._height - 40),
162            size=(0, 0),
163            text=sale_text
164            + (
165                (' ' + bui.Lstr(resource='store.oneTimeOnlyText').evaluate())
166                if self._offer['oneTimeOnly']
167                else ''
168            )
169            + percent_off_text,
170            h_align='center',
171            v_align='center',
172            maxwidth=self._width * 0.9 - 220,
173            scale=1.4,
174            color=(0.3, 1, 0.3),
175        )
176
177        self._flash_on = False
178        self._flashing_timer: bui.AppTimer | None = bui.AppTimer(
179            0.05, bui.WeakCall(self._flash_cycle), repeat=True
180        )
181        bui.apptimer(0.6, bui.WeakCall(self._stop_flashing))
182
183        size = store.get_store_item_display_size(self._offer_item)
184        display: dict[str, Any] = {}
185        storeitemui.instantiate_store_item_display(
186            self._offer_item,
187            display,
188            parent_widget=self._root_widget,
189            b_pos=(
190                self._width * 0.5
191                - size[0] * 0.5
192                + 10
193                - ((size[0] * 0.5 + 30) if self._is_bundle_sale else 0),
194                self._height * 0.5
195                - size[1] * 0.5
196                + 20
197                + (20 if self._is_bundle_sale else 0),
198            ),
199            b_width=size[0],
200            b_height=size[1],
201            button=not self._is_bundle_sale,
202        )
203
204        # Wire up the parts we need.
205        if self._is_bundle_sale:
206            self._plus_text = bui.textwidget(
207                parent=self._root_widget,
208                position=(self._width * 0.5, self._height * 0.5 + 50),
209                size=(0, 0),
210                text='+',
211                h_align='center',
212                v_align='center',
213                maxwidth=self._width * 0.9,
214                scale=1.4,
215                color=(0.5, 0.5, 0.5),
216            )
217            self._plus_tickets = bui.textwidget(
218                parent=self._root_widget,
219                position=(self._width * 0.5 + 120, self._height * 0.5 + 50),
220                size=(0, 0),
221                text=bui.charstr(SpecialChar.TICKET_BACKING)
222                + str(offer['bonusTickets']),
223                h_align='center',
224                v_align='center',
225                maxwidth=self._width * 0.9,
226                scale=2.5,
227                color=(0.2, 1, 0.2),
228            )
229            self._price_text = bui.textwidget(
230                parent=self._root_widget,
231                position=(self._width * 0.5, 150),
232                size=(0, 0),
233                text=real_price,
234                h_align='center',
235                v_align='center',
236                maxwidth=self._width * 0.9,
237                scale=1.4,
238                color=(0.2, 1, 0.2),
239            )
240            # Total-value if they supplied it.
241            total_worth_item = offer.get('valueItem', None)
242            if total_worth_item is not None:
243                price = plus.get_price(total_worth_item)
244                total_worth_price = (
245                    store.get_clean_price(price) if price is not None else None
246                )
247                if total_worth_price is not None:
248                    total_worth_text = bui.Lstr(
249                        resource='store.totalWorthText',
250                        subs=[('${TOTAL_WORTH}', total_worth_price)],
251                    )
252                    self._total_worth_text = bui.textwidget(
253                        parent=self._root_widget,
254                        text=total_worth_text,
255                        position=(self._width * 0.5, 210),
256                        scale=0.9,
257                        maxwidth=self._width * 0.7,
258                        size=(0, 0),
259                        h_align='center',
260                        v_align='center',
261                        shadow=1.0,
262                        flatness=1.0,
263                        color=(0.3, 1, 1),
264                    )
265
266        elif offer['item'] == 'pro_fullprice':
267            # for full-price pro we simply show full price
268            bui.textwidget(edit=display['price_widget'], text=real_price)
269            bui.buttonwidget(
270                edit=display['button'], on_activate_call=self._purchase
271            )
272        else:
273            # Show old/new prices otherwise (for pro sale).
274            bui.buttonwidget(
275                edit=display['button'], on_activate_call=self._purchase
276            )
277            bui.imagewidget(edit=display['price_slash_widget'], opacity=1.0)
278            bui.textwidget(
279                edit=display['price_widget_left'], text=original_price_str
280            )
281            bui.textwidget(
282                edit=display['price_widget_right'], text=new_price_str
283            )
284
285        # Add ticket button only if this is ticket-purchasable.
286        if isinstance(offer.get('price'), int):
287            self._get_tickets_button = bui.buttonwidget(
288                parent=self._root_widget,
289                position=(self._width - 125, self._height - 68),
290                size=(90, 55),
291                scale=1.0,
292                button_type='square',
293                color=(0.7, 0.5, 0.85),
294                textcolor=(0.2, 1, 0.2),
295                autoselect=True,
296                label=bui.Lstr(resource='getTicketsWindow.titleText'),
297                on_activate_call=self._on_get_more_tickets_press,
298            )
299
300            self._ticket_text_update_timer = bui.AppTimer(
301                1.0, bui.WeakCall(self._update_tickets_text), repeat=True
302            )
303            self._update_tickets_text()
304
305        self._update_timer = bui.AppTimer(
306            1.0, bui.WeakCall(self._update), repeat=True
307        )
308
309        self._cancel_button = bui.buttonwidget(
310            parent=self._root_widget,
311            position=(
312                (50, 40)
313                if self._is_bundle_sale
314                else (self._width * 0.5 - 75, 40)
315            ),
316            size=(150, 60),
317            scale=1.0,
318            on_activate_call=self._cancel,
319            autoselect=True,
320            label=bui.Lstr(resource='noThanksText'),
321        )
322        self._cancel_countdown_text = bui.textwidget(
323            parent=self._root_widget,
324            text='',
325            position=(
326                (50 + 150 + 20, 40 + 27)
327                if self._is_bundle_sale
328                else (self._width * 0.5 - 75 + 150 + 20, 40 + 27)
329            ),
330            scale=1.1,
331            size=(0, 0),
332            h_align='left',
333            v_align='center',
334            shadow=1.0,
335            flatness=1.0,
336            color=(0.6, 0.5, 0.5),
337        )
338        self._update_cancel_button_graphics()
339
340        if self._is_bundle_sale:
341            self._purchase_button = bui.buttonwidget(
342                parent=self._root_widget,
343                position=(self._width - 200, 40),
344                size=(150, 60),
345                scale=1.0,
346                on_activate_call=self._purchase,
347                autoselect=True,
348                label=bui.Lstr(resource='store.purchaseText'),
349            )
350
351        bui.containerwidget(
352            edit=self._root_widget,
353            cancel_button=self._cancel_button,
354            start_button=(
355                self._purchase_button if self._is_bundle_sale else None
356            ),
357            selected_child=(
358                self._purchase_button
359                if self._is_bundle_sale
360                else display['button']
361            ),
362        )
363
364    def _stop_flashing(self) -> None:
365        self._flashing_timer = None
366        bui.textwidget(edit=self._title_text, color=(0.3, 1, 0.3))
367
368    def _flash_cycle(self) -> None:
369        if not self._root_widget:
370            return
371        self._flash_on = not self._flash_on
372        bui.textwidget(
373            edit=self._title_text,
374            color=(0.3, 1, 0.3) if self._flash_on else (1, 0.5, 0),
375        )
376
377    def _update_cancel_button_graphics(self) -> None:
378        bui.buttonwidget(
379            edit=self._cancel_button,
380            color=(
381                (0.5, 0.5, 0.5) if self._cancel_delay > 0 else (0.7, 0.4, 0.34)
382            ),
383            textcolor=(
384                (0.5, 0.5, 0.5) if self._cancel_delay > 0 else (0.9, 0.9, 1.0)
385            ),
386        )
387        bui.textwidget(
388            edit=self._cancel_countdown_text,
389            text=str(self._cancel_delay) if self._cancel_delay > 0 else '',
390        )
391
392    def _update(self) -> None:
393        plus = bui.app.plus
394        assert plus is not None
395
396        # If we've got seconds left on our countdown, update it.
397        if self._cancel_delay > 0:
398            self._cancel_delay = max(0, self._cancel_delay - 1)
399            self._update_cancel_button_graphics()
400
401        can_die = False
402
403        # We go away if we see that our target item is owned.
404        if self._offer_item == 'pro':
405            assert bui.app.classic is not None
406            if bui.app.classic.accounts.have_pro():
407                can_die = True
408        else:
409            if plus.get_purchased(self._offer_item):
410                can_die = True
411
412        if can_die:
413            self._transition_out('out_left')
414
415    def _transition_out(self, transition: str = 'out_left') -> None:
416        # Also clear any pending-special-offer we've stored at this point.
417        cfg = bui.app.config
418        if 'pendingSpecialOffer' in cfg:
419            del cfg['pendingSpecialOffer']
420            cfg.commit()
421
422        bui.containerwidget(edit=self._root_widget, transition=transition)
423
424    def _update_tickets_text(self) -> None:
425        from babase import SpecialChar
426
427        plus = bui.app.plus
428        assert plus is not None
429
430        if not self._root_widget:
431            return
432        sval: str | bui.Lstr
433        if plus.get_v1_account_state() == 'signed_in':
434            sval = bui.charstr(SpecialChar.TICKET) + str(
435                plus.get_v1_account_ticket_count()
436            )
437        else:
438            sval = bui.Lstr(resource='getTicketsWindow.titleText')
439        bui.buttonwidget(edit=self._get_tickets_button, label=sval)
440
441    def _on_get_more_tickets_press(self) -> None:
442        from bauiv1lib import account
443        from bauiv1lib import getcurrency
444
445        plus = bui.app.plus
446        assert plus is not None
447
448        if plus.get_v1_account_state() != 'signed_in':
449            account.show_sign_in_prompt()
450            return
451        getcurrency.GetCurrencyWindow(modal=True).get_root_widget()
452
453    def _purchase(self) -> None:
454        from bauiv1lib import getcurrency
455        from bauiv1lib import confirm
456
457        plus = bui.app.plus
458        assert plus is not None
459
460        assert bui.app.classic is not None
461        store = bui.app.classic.store
462
463        if self._offer['item'] == 'pro':
464            plus.purchase('pro_sale')
465        elif self._offer['item'] == 'pro_fullprice':
466            plus.purchase('pro')
467        elif self._is_bundle_sale:
468            # With bundle sales, the price is the name of the IAP.
469            plus.purchase(self._offer['price'])
470        else:
471            ticket_count: int | None
472            try:
473                ticket_count = plus.get_v1_account_ticket_count()
474            except Exception:
475                ticket_count = None
476            if ticket_count is not None and ticket_count < self._offer['price']:
477                getcurrency.show_get_tickets_prompt()
478                bui.getsound('error').play()
479                return
480
481            def do_it() -> None:
482                assert plus is not None
483
484                plus.in_game_purchase(
485                    'offer:' + str(self._offer['id']), self._offer['price']
486                )
487
488            bui.getsound('swish').play()
489            confirm.ConfirmWindow(
490                bui.Lstr(
491                    resource='store.purchaseConfirmText',
492                    subs=[
493                        (
494                            '${ITEM}',
495                            store.get_store_item_name_translated(
496                                self._offer['item']
497                            ),
498                        )
499                    ],
500                ),
501                width=400,
502                height=120,
503                action=do_it,
504                ok_text=bui.Lstr(
505                    resource='store.purchaseText', fallback_resource='okText'
506                ),
507            )
508
509    def _cancel(self) -> None:
510        if self._cancel_delay > 0:
511            bui.getsound('error').play()
512            return
513        self._transition_out('out_right')
514
515
516def show_offer() -> bool:
517    """(internal)"""
518    try:
519        from bauiv1lib import feedback
520
521        plus = bui.app.plus
522        assert plus is not None
523
524        app = bui.app
525        if app.classic is None:
526            raise RuntimeError(
527                'Classic feature-set is required to show offers.'
528            )
529
530        # Space things out a bit so we don't hit the poor user with an
531        # ad and then an in-game offer.
532        has_been_long_enough_since_ad = True
533        if app.classic.ads.last_ad_completion_time is not None and (
534            bui.apptime() - app.classic.ads.last_ad_completion_time < 30.0
535        ):
536            has_been_long_enough_since_ad = False
537
538        if (
539            app.classic.special_offer is not None
540            and has_been_long_enough_since_ad
541        ):
542            # Special case: for pro offers, store this in our prefs so
543            # we can re-show it if the user kills us (set phasers to
544            # 'NAG'!!!).
545            if app.classic.special_offer.get('item') == 'pro_fullprice':
546                cfg = app.config
547                cfg['pendingSpecialOffer'] = {
548                    'a': plus.get_v1_account_public_login_id(),
549                    'o': app.classic.special_offer,
550                }
551                cfg.commit()
552
553            if app.classic.special_offer['item'] == 'rating':
554                # Go with a native thing if we've got one.
555                if bui.native_review_request_supported():
556                    bui.native_review_request()
557                else:
558                    if app.ui_v1.available:
559                        feedback.ask_for_rating()
560            else:
561                if app.ui_v1.available:
562                    SpecialOfferWindow(app.classic.special_offer)
563
564            app.classic.special_offer = None
565            return True
566    except Exception:
567        logging.exception('Error showing offer.')
568
569    return False
class SpecialOfferWindow(bauiv1._uitypes.Window):
 18class SpecialOfferWindow(bui.Window):
 19    """Window for presenting sales/etc."""
 20
 21    def __init__(self, offer: dict[str, Any], transition: str = 'in_right'):
 22        # pylint: disable=too-many-statements
 23        # pylint: disable=too-many-branches
 24        # pylint: disable=too-many-locals
 25        from babase import SpecialChar
 26        from bauiv1lib.store import item as storeitemui
 27
 28        plus = bui.app.plus
 29        assert plus is not None
 30
 31        assert bui.app.classic is not None
 32        store = bui.app.classic.store
 33
 34        self._cancel_delay = offer.get('cancelDelay', 0)
 35
 36        # First thing: if we're offering pro or an IAP, see if we have a
 37        # price for it.
 38        # If not, abort and go into zombie mode (the user should never see
 39        # us that way).
 40
 41        real_price: str | None
 42
 43        # Misnomer: 'pro' actually means offer 'pro_sale'.
 44        if offer['item'] in ['pro', 'pro_fullprice']:
 45            real_price = plus.get_price(
 46                'pro' if offer['item'] == 'pro_fullprice' else 'pro_sale'
 47            )
 48            if real_price is None and bui.app.env.debug:
 49                print('NOTE: Faking prices for debug build.')
 50                real_price = '$1.23'
 51            zombie = real_price is None
 52        elif isinstance(offer['price'], str):
 53            # (a string price implies IAP id)
 54            real_price = plus.get_price(offer['price'])
 55            if real_price is None and bui.app.env.debug:
 56                print('NOTE: Faking price for debug build.')
 57                real_price = '$1.23'
 58            zombie = real_price is None
 59        else:
 60            real_price = None
 61            zombie = False
 62        if real_price is None:
 63            real_price = '?'
 64
 65        if offer['item'] in ['pro', 'pro_fullprice']:
 66            self._offer_item = 'pro'
 67        else:
 68            self._offer_item = offer['item']
 69
 70        # If we wanted a real price but didn't find one, go zombie.
 71        if zombie:
 72            return
 73
 74        # This can pop up suddenly, so lets block input for 1 second.
 75        bui.lock_all_input()
 76        bui.apptimer(1.0, bui.unlock_all_input)
 77        bui.getsound('ding').play()
 78        bui.apptimer(0.3, bui.getsound('ooh').play)
 79        self._offer = copy.deepcopy(offer)
 80        self._width = 580
 81        self._height = 590
 82        uiscale = bui.app.ui_v1.uiscale
 83        super().__init__(
 84            root_widget=bui.containerwidget(
 85                size=(self._width, self._height),
 86                transition=transition,
 87                scale=(
 88                    1.2
 89                    if uiscale is bui.UIScale.SMALL
 90                    else 1.15 if uiscale is bui.UIScale.MEDIUM else 1.0
 91                ),
 92                stack_offset=(
 93                    (0, -15) if uiscale is bui.UIScale.SMALL else (0, 0)
 94                ),
 95            )
 96        )
 97        self._is_bundle_sale = False
 98        try:
 99            if offer['item'] in ['pro', 'pro_fullprice']:
100                original_price_str = plus.get_price('pro')
101                if original_price_str is None:
102                    original_price_str = '?'
103                new_price_str = plus.get_price('pro_sale')
104                if new_price_str is None:
105                    new_price_str = '?'
106                percent_off_text = ''
107            else:
108                # If the offer includes bonus tickets, it's a bundle-sale.
109                if (
110                    'bonusTickets' in offer
111                    and offer['bonusTickets'] is not None
112                ):
113                    self._is_bundle_sale = True
114                original_price = plus.get_v1_account_misc_read_val(
115                    'price.' + self._offer_item, 9999
116                )
117
118                # For pure ticket prices we can show a percent-off.
119                if isinstance(offer['price'], int):
120                    new_price = offer['price']
121                    tchar = bui.charstr(SpecialChar.TICKET)
122                    original_price_str = tchar + str(original_price)
123                    new_price_str = tchar + str(new_price)
124                    percent_off = int(
125                        round(
126                            100.0 - (float(new_price) / original_price) * 100.0
127                        )
128                    )
129                    percent_off_text = ' ' + bui.Lstr(
130                        resource='store.salePercentText'
131                    ).evaluate().replace('${PERCENT}', str(percent_off))
132                else:
133                    original_price_str = new_price_str = '?'
134                    percent_off_text = ''
135
136        except Exception:
137            logging.exception('Error setting up special-offer: %s.', offer)
138            original_price_str = new_price_str = '?'
139            percent_off_text = ''
140
141        # If its a bundle sale, change the title.
142        if self._is_bundle_sale:
143            sale_text = bui.Lstr(
144                resource='store.saleBundleText',
145                fallback_resource='store.saleText',
146            ).evaluate()
147        else:
148            # For full pro we say 'Upgrade?' since its not really a sale.
149            if offer['item'] == 'pro_fullprice':
150                sale_text = bui.Lstr(
151                    resource='store.upgradeQuestionText',
152                    fallback_resource='store.saleExclaimText',
153                ).evaluate()
154            else:
155                sale_text = bui.Lstr(
156                    resource='store.saleExclaimText',
157                    fallback_resource='store.saleText',
158                ).evaluate()
159
160        self._title_text = bui.textwidget(
161            parent=self._root_widget,
162            position=(self._width * 0.5, self._height - 40),
163            size=(0, 0),
164            text=sale_text
165            + (
166                (' ' + bui.Lstr(resource='store.oneTimeOnlyText').evaluate())
167                if self._offer['oneTimeOnly']
168                else ''
169            )
170            + percent_off_text,
171            h_align='center',
172            v_align='center',
173            maxwidth=self._width * 0.9 - 220,
174            scale=1.4,
175            color=(0.3, 1, 0.3),
176        )
177
178        self._flash_on = False
179        self._flashing_timer: bui.AppTimer | None = bui.AppTimer(
180            0.05, bui.WeakCall(self._flash_cycle), repeat=True
181        )
182        bui.apptimer(0.6, bui.WeakCall(self._stop_flashing))
183
184        size = store.get_store_item_display_size(self._offer_item)
185        display: dict[str, Any] = {}
186        storeitemui.instantiate_store_item_display(
187            self._offer_item,
188            display,
189            parent_widget=self._root_widget,
190            b_pos=(
191                self._width * 0.5
192                - size[0] * 0.5
193                + 10
194                - ((size[0] * 0.5 + 30) if self._is_bundle_sale else 0),
195                self._height * 0.5
196                - size[1] * 0.5
197                + 20
198                + (20 if self._is_bundle_sale else 0),
199            ),
200            b_width=size[0],
201            b_height=size[1],
202            button=not self._is_bundle_sale,
203        )
204
205        # Wire up the parts we need.
206        if self._is_bundle_sale:
207            self._plus_text = bui.textwidget(
208                parent=self._root_widget,
209                position=(self._width * 0.5, self._height * 0.5 + 50),
210                size=(0, 0),
211                text='+',
212                h_align='center',
213                v_align='center',
214                maxwidth=self._width * 0.9,
215                scale=1.4,
216                color=(0.5, 0.5, 0.5),
217            )
218            self._plus_tickets = bui.textwidget(
219                parent=self._root_widget,
220                position=(self._width * 0.5 + 120, self._height * 0.5 + 50),
221                size=(0, 0),
222                text=bui.charstr(SpecialChar.TICKET_BACKING)
223                + str(offer['bonusTickets']),
224                h_align='center',
225                v_align='center',
226                maxwidth=self._width * 0.9,
227                scale=2.5,
228                color=(0.2, 1, 0.2),
229            )
230            self._price_text = bui.textwidget(
231                parent=self._root_widget,
232                position=(self._width * 0.5, 150),
233                size=(0, 0),
234                text=real_price,
235                h_align='center',
236                v_align='center',
237                maxwidth=self._width * 0.9,
238                scale=1.4,
239                color=(0.2, 1, 0.2),
240            )
241            # Total-value if they supplied it.
242            total_worth_item = offer.get('valueItem', None)
243            if total_worth_item is not None:
244                price = plus.get_price(total_worth_item)
245                total_worth_price = (
246                    store.get_clean_price(price) if price is not None else None
247                )
248                if total_worth_price is not None:
249                    total_worth_text = bui.Lstr(
250                        resource='store.totalWorthText',
251                        subs=[('${TOTAL_WORTH}', total_worth_price)],
252                    )
253                    self._total_worth_text = bui.textwidget(
254                        parent=self._root_widget,
255                        text=total_worth_text,
256                        position=(self._width * 0.5, 210),
257                        scale=0.9,
258                        maxwidth=self._width * 0.7,
259                        size=(0, 0),
260                        h_align='center',
261                        v_align='center',
262                        shadow=1.0,
263                        flatness=1.0,
264                        color=(0.3, 1, 1),
265                    )
266
267        elif offer['item'] == 'pro_fullprice':
268            # for full-price pro we simply show full price
269            bui.textwidget(edit=display['price_widget'], text=real_price)
270            bui.buttonwidget(
271                edit=display['button'], on_activate_call=self._purchase
272            )
273        else:
274            # Show old/new prices otherwise (for pro sale).
275            bui.buttonwidget(
276                edit=display['button'], on_activate_call=self._purchase
277            )
278            bui.imagewidget(edit=display['price_slash_widget'], opacity=1.0)
279            bui.textwidget(
280                edit=display['price_widget_left'], text=original_price_str
281            )
282            bui.textwidget(
283                edit=display['price_widget_right'], text=new_price_str
284            )
285
286        # Add ticket button only if this is ticket-purchasable.
287        if isinstance(offer.get('price'), int):
288            self._get_tickets_button = bui.buttonwidget(
289                parent=self._root_widget,
290                position=(self._width - 125, self._height - 68),
291                size=(90, 55),
292                scale=1.0,
293                button_type='square',
294                color=(0.7, 0.5, 0.85),
295                textcolor=(0.2, 1, 0.2),
296                autoselect=True,
297                label=bui.Lstr(resource='getTicketsWindow.titleText'),
298                on_activate_call=self._on_get_more_tickets_press,
299            )
300
301            self._ticket_text_update_timer = bui.AppTimer(
302                1.0, bui.WeakCall(self._update_tickets_text), repeat=True
303            )
304            self._update_tickets_text()
305
306        self._update_timer = bui.AppTimer(
307            1.0, bui.WeakCall(self._update), repeat=True
308        )
309
310        self._cancel_button = bui.buttonwidget(
311            parent=self._root_widget,
312            position=(
313                (50, 40)
314                if self._is_bundle_sale
315                else (self._width * 0.5 - 75, 40)
316            ),
317            size=(150, 60),
318            scale=1.0,
319            on_activate_call=self._cancel,
320            autoselect=True,
321            label=bui.Lstr(resource='noThanksText'),
322        )
323        self._cancel_countdown_text = bui.textwidget(
324            parent=self._root_widget,
325            text='',
326            position=(
327                (50 + 150 + 20, 40 + 27)
328                if self._is_bundle_sale
329                else (self._width * 0.5 - 75 + 150 + 20, 40 + 27)
330            ),
331            scale=1.1,
332            size=(0, 0),
333            h_align='left',
334            v_align='center',
335            shadow=1.0,
336            flatness=1.0,
337            color=(0.6, 0.5, 0.5),
338        )
339        self._update_cancel_button_graphics()
340
341        if self._is_bundle_sale:
342            self._purchase_button = bui.buttonwidget(
343                parent=self._root_widget,
344                position=(self._width - 200, 40),
345                size=(150, 60),
346                scale=1.0,
347                on_activate_call=self._purchase,
348                autoselect=True,
349                label=bui.Lstr(resource='store.purchaseText'),
350            )
351
352        bui.containerwidget(
353            edit=self._root_widget,
354            cancel_button=self._cancel_button,
355            start_button=(
356                self._purchase_button if self._is_bundle_sale else None
357            ),
358            selected_child=(
359                self._purchase_button
360                if self._is_bundle_sale
361                else display['button']
362            ),
363        )
364
365    def _stop_flashing(self) -> None:
366        self._flashing_timer = None
367        bui.textwidget(edit=self._title_text, color=(0.3, 1, 0.3))
368
369    def _flash_cycle(self) -> None:
370        if not self._root_widget:
371            return
372        self._flash_on = not self._flash_on
373        bui.textwidget(
374            edit=self._title_text,
375            color=(0.3, 1, 0.3) if self._flash_on else (1, 0.5, 0),
376        )
377
378    def _update_cancel_button_graphics(self) -> None:
379        bui.buttonwidget(
380            edit=self._cancel_button,
381            color=(
382                (0.5, 0.5, 0.5) if self._cancel_delay > 0 else (0.7, 0.4, 0.34)
383            ),
384            textcolor=(
385                (0.5, 0.5, 0.5) if self._cancel_delay > 0 else (0.9, 0.9, 1.0)
386            ),
387        )
388        bui.textwidget(
389            edit=self._cancel_countdown_text,
390            text=str(self._cancel_delay) if self._cancel_delay > 0 else '',
391        )
392
393    def _update(self) -> None:
394        plus = bui.app.plus
395        assert plus is not None
396
397        # If we've got seconds left on our countdown, update it.
398        if self._cancel_delay > 0:
399            self._cancel_delay = max(0, self._cancel_delay - 1)
400            self._update_cancel_button_graphics()
401
402        can_die = False
403
404        # We go away if we see that our target item is owned.
405        if self._offer_item == 'pro':
406            assert bui.app.classic is not None
407            if bui.app.classic.accounts.have_pro():
408                can_die = True
409        else:
410            if plus.get_purchased(self._offer_item):
411                can_die = True
412
413        if can_die:
414            self._transition_out('out_left')
415
416    def _transition_out(self, transition: str = 'out_left') -> None:
417        # Also clear any pending-special-offer we've stored at this point.
418        cfg = bui.app.config
419        if 'pendingSpecialOffer' in cfg:
420            del cfg['pendingSpecialOffer']
421            cfg.commit()
422
423        bui.containerwidget(edit=self._root_widget, transition=transition)
424
425    def _update_tickets_text(self) -> None:
426        from babase import SpecialChar
427
428        plus = bui.app.plus
429        assert plus is not None
430
431        if not self._root_widget:
432            return
433        sval: str | bui.Lstr
434        if plus.get_v1_account_state() == 'signed_in':
435            sval = bui.charstr(SpecialChar.TICKET) + str(
436                plus.get_v1_account_ticket_count()
437            )
438        else:
439            sval = bui.Lstr(resource='getTicketsWindow.titleText')
440        bui.buttonwidget(edit=self._get_tickets_button, label=sval)
441
442    def _on_get_more_tickets_press(self) -> None:
443        from bauiv1lib import account
444        from bauiv1lib import getcurrency
445
446        plus = bui.app.plus
447        assert plus is not None
448
449        if plus.get_v1_account_state() != 'signed_in':
450            account.show_sign_in_prompt()
451            return
452        getcurrency.GetCurrencyWindow(modal=True).get_root_widget()
453
454    def _purchase(self) -> None:
455        from bauiv1lib import getcurrency
456        from bauiv1lib import confirm
457
458        plus = bui.app.plus
459        assert plus is not None
460
461        assert bui.app.classic is not None
462        store = bui.app.classic.store
463
464        if self._offer['item'] == 'pro':
465            plus.purchase('pro_sale')
466        elif self._offer['item'] == 'pro_fullprice':
467            plus.purchase('pro')
468        elif self._is_bundle_sale:
469            # With bundle sales, the price is the name of the IAP.
470            plus.purchase(self._offer['price'])
471        else:
472            ticket_count: int | None
473            try:
474                ticket_count = plus.get_v1_account_ticket_count()
475            except Exception:
476                ticket_count = None
477            if ticket_count is not None and ticket_count < self._offer['price']:
478                getcurrency.show_get_tickets_prompt()
479                bui.getsound('error').play()
480                return
481
482            def do_it() -> None:
483                assert plus is not None
484
485                plus.in_game_purchase(
486                    'offer:' + str(self._offer['id']), self._offer['price']
487                )
488
489            bui.getsound('swish').play()
490            confirm.ConfirmWindow(
491                bui.Lstr(
492                    resource='store.purchaseConfirmText',
493                    subs=[
494                        (
495                            '${ITEM}',
496                            store.get_store_item_name_translated(
497                                self._offer['item']
498                            ),
499                        )
500                    ],
501                ),
502                width=400,
503                height=120,
504                action=do_it,
505                ok_text=bui.Lstr(
506                    resource='store.purchaseText', fallback_resource='okText'
507                ),
508            )
509
510    def _cancel(self) -> None:
511        if self._cancel_delay > 0:
512            bui.getsound('error').play()
513            return
514        self._transition_out('out_right')

Window for presenting sales/etc.

SpecialOfferWindow(offer: dict[str, typing.Any], transition: str = 'in_right')
 21    def __init__(self, offer: dict[str, Any], transition: str = 'in_right'):
 22        # pylint: disable=too-many-statements
 23        # pylint: disable=too-many-branches
 24        # pylint: disable=too-many-locals
 25        from babase import SpecialChar
 26        from bauiv1lib.store import item as storeitemui
 27
 28        plus = bui.app.plus
 29        assert plus is not None
 30
 31        assert bui.app.classic is not None
 32        store = bui.app.classic.store
 33
 34        self._cancel_delay = offer.get('cancelDelay', 0)
 35
 36        # First thing: if we're offering pro or an IAP, see if we have a
 37        # price for it.
 38        # If not, abort and go into zombie mode (the user should never see
 39        # us that way).
 40
 41        real_price: str | None
 42
 43        # Misnomer: 'pro' actually means offer 'pro_sale'.
 44        if offer['item'] in ['pro', 'pro_fullprice']:
 45            real_price = plus.get_price(
 46                'pro' if offer['item'] == 'pro_fullprice' else 'pro_sale'
 47            )
 48            if real_price is None and bui.app.env.debug:
 49                print('NOTE: Faking prices for debug build.')
 50                real_price = '$1.23'
 51            zombie = real_price is None
 52        elif isinstance(offer['price'], str):
 53            # (a string price implies IAP id)
 54            real_price = plus.get_price(offer['price'])
 55            if real_price is None and bui.app.env.debug:
 56                print('NOTE: Faking price for debug build.')
 57                real_price = '$1.23'
 58            zombie = real_price is None
 59        else:
 60            real_price = None
 61            zombie = False
 62        if real_price is None:
 63            real_price = '?'
 64
 65        if offer['item'] in ['pro', 'pro_fullprice']:
 66            self._offer_item = 'pro'
 67        else:
 68            self._offer_item = offer['item']
 69
 70        # If we wanted a real price but didn't find one, go zombie.
 71        if zombie:
 72            return
 73
 74        # This can pop up suddenly, so lets block input for 1 second.
 75        bui.lock_all_input()
 76        bui.apptimer(1.0, bui.unlock_all_input)
 77        bui.getsound('ding').play()
 78        bui.apptimer(0.3, bui.getsound('ooh').play)
 79        self._offer = copy.deepcopy(offer)
 80        self._width = 580
 81        self._height = 590
 82        uiscale = bui.app.ui_v1.uiscale
 83        super().__init__(
 84            root_widget=bui.containerwidget(
 85                size=(self._width, self._height),
 86                transition=transition,
 87                scale=(
 88                    1.2
 89                    if uiscale is bui.UIScale.SMALL
 90                    else 1.15 if uiscale is bui.UIScale.MEDIUM else 1.0
 91                ),
 92                stack_offset=(
 93                    (0, -15) if uiscale is bui.UIScale.SMALL else (0, 0)
 94                ),
 95            )
 96        )
 97        self._is_bundle_sale = False
 98        try:
 99            if offer['item'] in ['pro', 'pro_fullprice']:
100                original_price_str = plus.get_price('pro')
101                if original_price_str is None:
102                    original_price_str = '?'
103                new_price_str = plus.get_price('pro_sale')
104                if new_price_str is None:
105                    new_price_str = '?'
106                percent_off_text = ''
107            else:
108                # If the offer includes bonus tickets, it's a bundle-sale.
109                if (
110                    'bonusTickets' in offer
111                    and offer['bonusTickets'] is not None
112                ):
113                    self._is_bundle_sale = True
114                original_price = plus.get_v1_account_misc_read_val(
115                    'price.' + self._offer_item, 9999
116                )
117
118                # For pure ticket prices we can show a percent-off.
119                if isinstance(offer['price'], int):
120                    new_price = offer['price']
121                    tchar = bui.charstr(SpecialChar.TICKET)
122                    original_price_str = tchar + str(original_price)
123                    new_price_str = tchar + str(new_price)
124                    percent_off = int(
125                        round(
126                            100.0 - (float(new_price) / original_price) * 100.0
127                        )
128                    )
129                    percent_off_text = ' ' + bui.Lstr(
130                        resource='store.salePercentText'
131                    ).evaluate().replace('${PERCENT}', str(percent_off))
132                else:
133                    original_price_str = new_price_str = '?'
134                    percent_off_text = ''
135
136        except Exception:
137            logging.exception('Error setting up special-offer: %s.', offer)
138            original_price_str = new_price_str = '?'
139            percent_off_text = ''
140
141        # If its a bundle sale, change the title.
142        if self._is_bundle_sale:
143            sale_text = bui.Lstr(
144                resource='store.saleBundleText',
145                fallback_resource='store.saleText',
146            ).evaluate()
147        else:
148            # For full pro we say 'Upgrade?' since its not really a sale.
149            if offer['item'] == 'pro_fullprice':
150                sale_text = bui.Lstr(
151                    resource='store.upgradeQuestionText',
152                    fallback_resource='store.saleExclaimText',
153                ).evaluate()
154            else:
155                sale_text = bui.Lstr(
156                    resource='store.saleExclaimText',
157                    fallback_resource='store.saleText',
158                ).evaluate()
159
160        self._title_text = bui.textwidget(
161            parent=self._root_widget,
162            position=(self._width * 0.5, self._height - 40),
163            size=(0, 0),
164            text=sale_text
165            + (
166                (' ' + bui.Lstr(resource='store.oneTimeOnlyText').evaluate())
167                if self._offer['oneTimeOnly']
168                else ''
169            )
170            + percent_off_text,
171            h_align='center',
172            v_align='center',
173            maxwidth=self._width * 0.9 - 220,
174            scale=1.4,
175            color=(0.3, 1, 0.3),
176        )
177
178        self._flash_on = False
179        self._flashing_timer: bui.AppTimer | None = bui.AppTimer(
180            0.05, bui.WeakCall(self._flash_cycle), repeat=True
181        )
182        bui.apptimer(0.6, bui.WeakCall(self._stop_flashing))
183
184        size = store.get_store_item_display_size(self._offer_item)
185        display: dict[str, Any] = {}
186        storeitemui.instantiate_store_item_display(
187            self._offer_item,
188            display,
189            parent_widget=self._root_widget,
190            b_pos=(
191                self._width * 0.5
192                - size[0] * 0.5
193                + 10
194                - ((size[0] * 0.5 + 30) if self._is_bundle_sale else 0),
195                self._height * 0.5
196                - size[1] * 0.5
197                + 20
198                + (20 if self._is_bundle_sale else 0),
199            ),
200            b_width=size[0],
201            b_height=size[1],
202            button=not self._is_bundle_sale,
203        )
204
205        # Wire up the parts we need.
206        if self._is_bundle_sale:
207            self._plus_text = bui.textwidget(
208                parent=self._root_widget,
209                position=(self._width * 0.5, self._height * 0.5 + 50),
210                size=(0, 0),
211                text='+',
212                h_align='center',
213                v_align='center',
214                maxwidth=self._width * 0.9,
215                scale=1.4,
216                color=(0.5, 0.5, 0.5),
217            )
218            self._plus_tickets = bui.textwidget(
219                parent=self._root_widget,
220                position=(self._width * 0.5 + 120, self._height * 0.5 + 50),
221                size=(0, 0),
222                text=bui.charstr(SpecialChar.TICKET_BACKING)
223                + str(offer['bonusTickets']),
224                h_align='center',
225                v_align='center',
226                maxwidth=self._width * 0.9,
227                scale=2.5,
228                color=(0.2, 1, 0.2),
229            )
230            self._price_text = bui.textwidget(
231                parent=self._root_widget,
232                position=(self._width * 0.5, 150),
233                size=(0, 0),
234                text=real_price,
235                h_align='center',
236                v_align='center',
237                maxwidth=self._width * 0.9,
238                scale=1.4,
239                color=(0.2, 1, 0.2),
240            )
241            # Total-value if they supplied it.
242            total_worth_item = offer.get('valueItem', None)
243            if total_worth_item is not None:
244                price = plus.get_price(total_worth_item)
245                total_worth_price = (
246                    store.get_clean_price(price) if price is not None else None
247                )
248                if total_worth_price is not None:
249                    total_worth_text = bui.Lstr(
250                        resource='store.totalWorthText',
251                        subs=[('${TOTAL_WORTH}', total_worth_price)],
252                    )
253                    self._total_worth_text = bui.textwidget(
254                        parent=self._root_widget,
255                        text=total_worth_text,
256                        position=(self._width * 0.5, 210),
257                        scale=0.9,
258                        maxwidth=self._width * 0.7,
259                        size=(0, 0),
260                        h_align='center',
261                        v_align='center',
262                        shadow=1.0,
263                        flatness=1.0,
264                        color=(0.3, 1, 1),
265                    )
266
267        elif offer['item'] == 'pro_fullprice':
268            # for full-price pro we simply show full price
269            bui.textwidget(edit=display['price_widget'], text=real_price)
270            bui.buttonwidget(
271                edit=display['button'], on_activate_call=self._purchase
272            )
273        else:
274            # Show old/new prices otherwise (for pro sale).
275            bui.buttonwidget(
276                edit=display['button'], on_activate_call=self._purchase
277            )
278            bui.imagewidget(edit=display['price_slash_widget'], opacity=1.0)
279            bui.textwidget(
280                edit=display['price_widget_left'], text=original_price_str
281            )
282            bui.textwidget(
283                edit=display['price_widget_right'], text=new_price_str
284            )
285
286        # Add ticket button only if this is ticket-purchasable.
287        if isinstance(offer.get('price'), int):
288            self._get_tickets_button = bui.buttonwidget(
289                parent=self._root_widget,
290                position=(self._width - 125, self._height - 68),
291                size=(90, 55),
292                scale=1.0,
293                button_type='square',
294                color=(0.7, 0.5, 0.85),
295                textcolor=(0.2, 1, 0.2),
296                autoselect=True,
297                label=bui.Lstr(resource='getTicketsWindow.titleText'),
298                on_activate_call=self._on_get_more_tickets_press,
299            )
300
301            self._ticket_text_update_timer = bui.AppTimer(
302                1.0, bui.WeakCall(self._update_tickets_text), repeat=True
303            )
304            self._update_tickets_text()
305
306        self._update_timer = bui.AppTimer(
307            1.0, bui.WeakCall(self._update), repeat=True
308        )
309
310        self._cancel_button = bui.buttonwidget(
311            parent=self._root_widget,
312            position=(
313                (50, 40)
314                if self._is_bundle_sale
315                else (self._width * 0.5 - 75, 40)
316            ),
317            size=(150, 60),
318            scale=1.0,
319            on_activate_call=self._cancel,
320            autoselect=True,
321            label=bui.Lstr(resource='noThanksText'),
322        )
323        self._cancel_countdown_text = bui.textwidget(
324            parent=self._root_widget,
325            text='',
326            position=(
327                (50 + 150 + 20, 40 + 27)
328                if self._is_bundle_sale
329                else (self._width * 0.5 - 75 + 150 + 20, 40 + 27)
330            ),
331            scale=1.1,
332            size=(0, 0),
333            h_align='left',
334            v_align='center',
335            shadow=1.0,
336            flatness=1.0,
337            color=(0.6, 0.5, 0.5),
338        )
339        self._update_cancel_button_graphics()
340
341        if self._is_bundle_sale:
342            self._purchase_button = bui.buttonwidget(
343                parent=self._root_widget,
344                position=(self._width - 200, 40),
345                size=(150, 60),
346                scale=1.0,
347                on_activate_call=self._purchase,
348                autoselect=True,
349                label=bui.Lstr(resource='store.purchaseText'),
350            )
351
352        bui.containerwidget(
353            edit=self._root_widget,
354            cancel_button=self._cancel_button,
355            start_button=(
356                self._purchase_button if self._is_bundle_sale else None
357            ),
358            selected_child=(
359                self._purchase_button
360                if self._is_bundle_sale
361                else display['button']
362            ),
363        )
Inherited Members
bauiv1._uitypes.Window
get_root_widget