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