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