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