diff --git a/assets/images/envelope.png b/assets/images/envelope.png new file mode 100644 index 000000000..eaa55bd04 Binary files /dev/null and b/assets/images/envelope.png differ diff --git a/lib/cake_pay/cake_pay.dart b/lib/cake_pay/cake_pay.dart new file mode 100644 index 000000000..c24878fad --- /dev/null +++ b/lib/cake_pay/cake_pay.dart @@ -0,0 +1,5 @@ +export 'src/auth/cake_pay_welcome_page.dart'; +export 'src/auth/cake_pay_verify_otp_page.dart'; +export 'src/auth/cake_pay_account_page.dart'; +export 'src/cards/cake_pay_cards_page.dart'; +export 'src/cards/cake_pay_buy_card_page.dart'; diff --git a/lib/cake_pay/cake_pay_payment_credantials.dart b/lib/cake_pay/cake_pay_payment_credantials.dart deleted file mode 100644 index 12c4ce6c2..000000000 --- a/lib/cake_pay/cake_pay_payment_credantials.dart +++ /dev/null @@ -1,15 +0,0 @@ -class PaymentCredential { - final double amount; - final int quantity; - final double totalAmount; - final String? userName; - final String fiatCurrency; - - PaymentCredential({ - required this.amount, - required this.quantity, - required this.totalAmount, - required this.userName, - required this.fiatCurrency, - }); -} \ No newline at end of file diff --git a/lib/src/screens/cake_pay/auth/cake_pay_account_page.dart b/lib/cake_pay/src/auth/cake_pay_account_page.dart similarity index 97% rename from lib/src/screens/cake_pay/auth/cake_pay_account_page.dart rename to lib/cake_pay/src/auth/cake_pay_account_page.dart index 68914402e..c0cb28f0e 100644 --- a/lib/src/screens/cake_pay/auth/cake_pay_account_page.dart +++ b/lib/cake_pay/src/auth/cake_pay_account_page.dart @@ -1,7 +1,7 @@ import 'package:cake_wallet/routes.dart'; import 'package:cake_wallet/src/screens/base_page.dart'; import 'package:cake_wallet/generated/i18n.dart'; -import 'package:cake_wallet/src/screens/cake_pay/widgets/cake_pay_tile.dart'; +import 'package:cake_wallet/cake_pay/src/widgets/cake_pay_tile.dart'; import 'package:cake_wallet/src/widgets/primary_button.dart'; import 'package:cake_wallet/src/widgets/scollable_with_bottom_section.dart'; import 'package:cake_wallet/view_model/cake_pay/cake_pay_account_view_model.dart'; diff --git a/lib/src/screens/cake_pay/auth/cake_pay_verify_otp_page.dart b/lib/cake_pay/src/auth/cake_pay_verify_otp_page.dart similarity index 96% rename from lib/src/screens/cake_pay/auth/cake_pay_verify_otp_page.dart rename to lib/cake_pay/src/auth/cake_pay_verify_otp_page.dart index 15a20b80a..4647809cb 100644 --- a/lib/src/screens/cake_pay/auth/cake_pay_verify_otp_page.dart +++ b/lib/cake_pay/src/auth/cake_pay_verify_otp_page.dart @@ -1,5 +1,6 @@ import 'package:cake_wallet/generated/i18n.dart'; -import 'package:cake_wallet/cake_pay/cake_pay_states.dart'; +import 'package:cake_wallet/cake_pay/src/cake_pay_states.dart'; +import 'package:cake_wallet/palette.dart'; import 'package:cake_wallet/src/screens/base_page.dart'; import 'package:cake_wallet/src/widgets/alert_with_one_action.dart'; import 'package:cake_wallet/src/widgets/base_text_form_field.dart'; @@ -146,7 +147,9 @@ class CakePayVerifyOtpPage extends BasePage { }); } - void _onOtpSuccessful(BuildContext context) => Navigator.pop(context); + + void _onOtpSuccessful(BuildContext context) => + Navigator.pop(context, true); void _verify() async => await _authViewModel.verifyEmail(_codeController.text); } diff --git a/lib/src/screens/cake_pay/auth/cake_pay_welcome_page.dart b/lib/cake_pay/src/auth/cake_pay_welcome_page.dart similarity index 91% rename from lib/src/screens/cake_pay/auth/cake_pay_welcome_page.dart rename to lib/cake_pay/src/auth/cake_pay_welcome_page.dart index a445371f3..213109ebd 100644 --- a/lib/src/screens/cake_pay/auth/cake_pay_welcome_page.dart +++ b/lib/cake_pay/src/auth/cake_pay_welcome_page.dart @@ -1,6 +1,6 @@ import 'package:cake_wallet/core/email_validator.dart'; import 'package:cake_wallet/generated/i18n.dart'; -import 'package:cake_wallet/cake_pay/cake_pay_states.dart'; +import 'package:cake_wallet/cake_pay/src/cake_pay_states.dart'; import 'package:cake_wallet/routes.dart'; import 'package:cake_wallet/src/screens/base_page.dart'; import 'package:cake_wallet/src/widgets/alert_with_one_action.dart'; @@ -113,12 +113,14 @@ class CakePayWelcomePage extends BasePage { }); } - void _onLoginSuccessful(BuildContext context, CakePayAuthViewModel authViewModel) => - Navigator.pushReplacementNamed( - context, - Routes.cakePayVerifyOtpPage, - arguments: [authViewModel.email, true], - ); + Future _onLoginSuccessful(BuildContext context, CakePayAuthViewModel authViewModel) async { + final verified = await Navigator.pushNamed(context, Routes.cakePayVerifyOtpPage, + arguments: [authViewModel.email, true]); + + if (verified == true) { + Navigator.pop(context, true); + } + } void _login() async { if (_formKey.currentState != null && !_formKey.currentState!.validate()) { diff --git a/lib/cake_pay/cake_pay_states.dart b/lib/cake_pay/src/cake_pay_states.dart similarity index 76% rename from lib/cake_pay/cake_pay_states.dart rename to lib/cake_pay/src/cake_pay_states.dart index 2c49353c1..93b9afc79 100644 --- a/lib/cake_pay/cake_pay_states.dart +++ b/lib/cake_pay/src/cake_pay_states.dart @@ -1,5 +1,3 @@ -import 'cake_pay_card.dart'; - abstract class CakePayUserVerificationState {} class CakePayUserVerificationStateInitial extends CakePayUserVerificationState {} @@ -44,20 +42,23 @@ class CakePayCreateCardStateFailure extends CakePayCreateCardState { final String error; } -class CakePayCardsState {} +class UserCakePayCardsState {} -class CakePayCardsStateNoCards extends CakePayCardsState {} +class UserCakePayCardsStateInitial extends UserCakePayCardsState {} -class CakePayCardsStateFetching extends CakePayCardsState {} +class UserCakePayCardsStateNoCards extends UserCakePayCardsState {} -class CakePayCardsStateFailure extends CakePayCardsState {} +class UserCakePayCardsStateFetching extends UserCakePayCardsState {} -class CakePayCardsStateSuccess extends CakePayCardsState { - CakePayCardsStateSuccess({required this.card}); +class UserCakePayCardsStateFailure extends UserCakePayCardsState { + UserCakePayCardsStateFailure({required this.error}); - final CakePayCard card; + final String error; } +class UserCakePayCardsStateSuccess extends UserCakePayCardsState {} + + abstract class CakePayVendorState {} class InitialCakePayVendorLoadingState extends CakePayVendorState {} diff --git a/lib/cake_pay/src/cards/cake_pay_buy_card_page.dart b/lib/cake_pay/src/cards/cake_pay_buy_card_page.dart new file mode 100644 index 000000000..77aa40cb1 --- /dev/null +++ b/lib/cake_pay/src/cards/cake_pay_buy_card_page.dart @@ -0,0 +1,773 @@ +import 'dart:io'; + +import 'package:cake_wallet/bitcoin/bitcoin.dart'; +import 'package:cake_wallet/cake_pay/src/models/cake_pay_card.dart'; +import 'package:cake_wallet/cake_pay/src/models/cake_pay_order.dart'; +import 'package:cake_wallet/cake_pay/src/services/cake_pay_service.dart'; +import 'package:cake_wallet/cake_pay/src/widgets/cake_pay_alert_modal.dart'; +import 'package:cake_wallet/cake_pay/src/widgets/denominations_amount_widget.dart'; +import 'package:cake_wallet/cake_pay/src/widgets/enter_amount_widget.dart'; +import 'package:cake_wallet/cake_pay/src/widgets/image_placeholder.dart'; +import 'package:cake_wallet/cake_pay/src/widgets/link_extractor.dart'; +import 'package:cake_wallet/cake_pay/src/widgets/rounded_overlay_cards_widget.dart'; +import 'package:cake_wallet/cake_pay/src/widgets/text_icon_button.dart'; +import 'package:cake_wallet/cake_pay/src/widgets/three_checkbox_alert_content_widget.dart'; +import 'package:cake_wallet/core/execution_state.dart'; +import 'package:cake_wallet/entities/parsed_address.dart'; +import 'package:cake_wallet/generated/i18n.dart'; +import 'package:cake_wallet/routes.dart'; +import 'package:cake_wallet/src/screens/base_page.dart'; +import 'package:cake_wallet/src/widgets/alert_with_one_action.dart'; +import 'package:cake_wallet/src/widgets/bottom_sheet/base_bottom_sheet_widget.dart'; +import 'package:cake_wallet/src/widgets/bottom_sheet/cake_pay_transaction_sent_bottom_sheet.dart'; +import 'package:cake_wallet/src/widgets/bottom_sheet/confirm_sending_bottom_sheet_widget.dart'; +import 'package:cake_wallet/src/widgets/bottom_sheet/info_bottom_sheet_widget.dart'; +import 'package:cake_wallet/src/widgets/keyboard_done_button.dart'; +import 'package:cake_wallet/src/widgets/primary_button.dart'; +import 'package:cake_wallet/typography.dart'; +import 'package:cake_wallet/utils/feature_flag.dart'; +import 'package:cake_wallet/utils/show_pop_up.dart'; +import 'package:cake_wallet/view_model/cake_pay/cake_pay_buy_card_view_model.dart'; +import 'package:cake_wallet/view_model/send/output.dart'; +import 'package:cake_wallet/view_model/send/send_view_model_state.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_mobx/flutter_mobx.dart'; +import 'package:keyboard_actions/keyboard_actions.dart'; +import 'package:mobx/mobx.dart'; + +class CakePayBuyCardPage extends BasePage { + CakePayBuyCardPage( + this.cakePayBuyCardViewModel, + this.cakePayService, + ) : _amountFieldFocus = FocusNode(), + _amountController = TextEditingController(), + _quantityFieldFocus = FocusNode(), + _quantityController = + TextEditingController(text: cakePayBuyCardViewModel.quantity.toString()) { + _amountController.addListener(() { + cakePayBuyCardViewModel.onAmountChanged(_amountController.text); + }); + } + + final CakePayBuyCardViewModel cakePayBuyCardViewModel; + final CakePayService cakePayService; + + bool _effectsInstalled = false; + late final BuildContext _overlayCtx; + + @override + String get title => cakePayBuyCardViewModel.card.name; + + @override + bool get extendBodyBehindAppBar => true; + + @override + bool get gradientAll => true; + + @override + AppBarStyle get appBarStyle => AppBarStyle.completelyTransparent; + + @override + Widget? trailing(BuildContext context) { + return const SizedBox( + width: 54, + height: 0, + ); + } + + @override + Widget? middle(BuildContext context) { + return Text( + title, + textAlign: TextAlign.center, + maxLines: 2, + style: TextStyle( + fontSize: 18.0, + fontWeight: FontWeight.bold, + fontFamily: 'Lato', + color: titleColor(context)), + ); + } + + final TextEditingController _amountController; + final FocusNode _amountFieldFocus; + final TextEditingController _quantityController; + final FocusNode _quantityFieldFocus; + + @override + Widget body(BuildContext context) { + _setEffects(context); + + final card = cakePayBuyCardViewModel.card; + final vendor = cakePayBuyCardViewModel.vendor; + + return KeyboardActions( + disableScroll: true, + config: KeyboardActionsConfig( + keyboardActionsPlatform: KeyboardActionsPlatform.IOS, + keyboardBarColor: Theme.of(context).primaryColor, + nextFocus: false, + actions: [ + KeyboardActionsItem( + focusNode: _amountFieldFocus, + toolbarButtons: [(_) => KeyboardDoneButton()], + ), + ]), + child: Container( + color: Colors.transparent, + child: Column( + children: [ + RoundedOverlayCards( + topCardChild: Column( + children: [ + Expanded(flex: 4, child: const SizedBox()), + Expanded( + flex: 7, + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10), + boxShadow: [ + BoxShadow( + color: Colors.black.withAlpha(150), + blurRadius: 8, + offset: const Offset(0, 2)) + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(10), + child: Image.network( + card.cardImageUrl ?? '', + fit: BoxFit.cover, + loadingBuilder: (BuildContext context, Widget child, + ImageChunkEvent? loadingProgress) { + if (loadingProgress == null) return child; + return Center(child: CircularProgressIndicator()); + }, + errorBuilder: (context, error, stackTrace) => + CakePayCardImagePlaceholder(), + ), + ), + ), + ), + Expanded(child: const SizedBox()), + ], + ), + bottomCardChild: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: card.denominations.isNotEmpty + ? DenominationsAmountWidget( + fiatCurrency: card.fiatCurrency.title, + denominations: card.denominations, + amountFieldFocus: _amountFieldFocus, + amountController: _amountController, + quantityFieldFocus: _quantityFieldFocus, + quantityController: _quantityController, + onAmountChanged: cakePayBuyCardViewModel.onAmountChanged, + onQuantityChanged: cakePayBuyCardViewModel.onQuantityChanged, + cakePayBuyCardViewModel: cakePayBuyCardViewModel) + : EnterAmountWidget( + minValue: card.minValue ?? '-', + maxValue: card.maxValue ?? '-', + fiatCurrency: card.fiatCurrency.title, + amountFieldFocus: _amountFieldFocus, + amountController: _amountController, + onAmountChanged: cakePayBuyCardViewModel.onAmountChanged))), + Expanded( + flex: 2, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (vendor.cakeWarnings != null) + Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Center( + child: Container( + decoration: BoxDecoration( + color: Theme.of(context).primaryColor, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: Colors.white.withAlpha(50)), + ), + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Text( + vendor.cakeWarnings!, + textAlign: TextAlign.center, + style: textSmallSemiBold(color: Colors.white), + ), + ), + ), + ), + ), + Expanded( + child: SingleChildScrollView( + primary: false, + padding: EdgeInsets.zero, + child: ClickableLinksText( + text: card.description ?? '', + textStyle: TextStyle( + color: Theme.of(context).textTheme.titleLarge!.color!, + fontSize: 14, + fontWeight: FontWeight.w400, + ), + ), + ), + ), + ], + ), + ), + ), + Expanded( + child: SingleChildScrollView( + primary: false, + child: Padding( + padding: const EdgeInsets.only(left: 24, right: 24), + child: Column( + children: [ + if (card.expiryAndValidity != null && card.expiryAndValidity!.isNotEmpty) + Row( + children: [ + Text(S.of(context).expiry_and_validity + ':', + style: TextStyle( + color: Theme.of(context).textTheme.titleLarge!.color!, + fontSize: 16, + fontWeight: FontWeight.w900)), + Expanded( + child: Text(card.expiryAndValidity!, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.labelMedium)), + ], + ), + SizedBox(height: 8), + TextIconButton( + label: S.of(context).how_to_use_card, + onTap: () => _showHowToUseCard(context, card)), + SizedBox(height: 8), + TextIconButton( + label: S.of(context).settings_terms_and_conditions, + onTap: () => _showTermsAndCondition(context, card.termsAndConditions)), + ], + ), + ), + ), + ), + SizedBox(height: 8), + Observer(builder: (_) { + Widget _buildPaymentMethodWidget( + List methods, CakePayPaymentMethod selected) { + return Row( + children: [ + Padding( + padding: const EdgeInsets.only(left: 24, right: 8), + child: Text( + 'Payment Method', + style: TextStyle( + color: Theme.of(context).textTheme.titleLarge!.color!, + fontSize: 16, + fontWeight: FontWeight.w900, + ), + ), + ), + Expanded(child: const SizedBox()), + if (methods.length > 1) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 6), + child: ToggleButtons( + isSelected: methods.map((m) => m == selected).toList(), + borderRadius: BorderRadius.circular(8), + onPressed: (index) => + cakePayBuyCardViewModel.chooseMethod(methods[index]), + children: methods + .map((m) => Padding( + padding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Text(m.label), + )) + .toList(), + ), + ), + ], + ); + } + + final methods = cakePayBuyCardViewModel.availableMethods; + final selected = cakePayBuyCardViewModel.selectedPaymentMethod ?? + (methods.isNotEmpty ? methods.first : null); + + return Column( + children: [ + methods.length <= 1 || selected == null + ? const SizedBox.shrink() + : _buildPaymentMethodWidget(methods, selected), + if (FeatureFlag.hasDevOptions) + Padding( + padding: EdgeInsets.only(top: 10, bottom: 0, right: 20, left: 20), + child: LoadingPrimaryButton( + onPressed: () { + //Request dummy node to get the focus out of the text fields + FocusScope.of(context).requestFocus(FocusNode()); + + cakePayBuyCardViewModel.isSimulatingFlow = true; + isIOSUnavailable(card) + ? alertIOSAvailability(context, card) + : confirmPurchaseFirst(context); + }, + text: '(Dev) Simulate Purchasing Gift Card', + isDisabled: !cakePayBuyCardViewModel.isAmountSufficient || + cakePayBuyCardViewModel.isPurchasing, + isLoading: + cakePayBuyCardViewModel.sendViewModel.state is IsExecutingState || + cakePayBuyCardViewModel.isPurchasing, + color: Theme.of(context).colorScheme.primary, + textColor: Theme.of(context).colorScheme.onPrimary, + ), + ), + Padding( + padding: EdgeInsets.only(top: 10, bottom: 34, right: 20, left: 20), + child: LoadingPrimaryButton( + onPressed: () { + //Request dummy node to get the focus out of the text fields + FocusScope.of(context).requestFocus(FocusNode()); + + isIOSUnavailable(card) + ? alertIOSAvailability(context, card) + : confirmPurchaseFirst(context); + }, + text: S.of(context).purchase_gift_card, + isDisabled: !cakePayBuyCardViewModel.isAmountSufficient || + cakePayBuyCardViewModel.isPurchasing, + isLoading: cakePayBuyCardViewModel.sendViewModel.state is IsExecutingState || + cakePayBuyCardViewModel.isPurchasing, + color: Theme.of(context).colorScheme.primary, + textColor: Theme.of(context).colorScheme.onPrimary, + ), + ), + ], + ); + }), + ], + ), + ), + ); + } + + bool isWordInCardsName(CakePayCard card, String word) { + return card.name.toLowerCase().contains(word.toLowerCase()); + } + + bool isIOSUnavailable(CakePayCard card) { + if (!Platform.isIOS && !Platform.isMacOS) { + return false; + } + + final isDigitalGameStores = isWordInCardsName(card, 'playstation') || + isWordInCardsName(card, 'xbox') || + isWordInCardsName(card, 'steam') || + isWordInCardsName(card, 'meta quest') || + isWordInCardsName(card, 'kigso') || + isWordInCardsName(card, 'game world') || + isWordInCardsName(card, 'google') || + isWordInCardsName(card, 'nintendo'); + final isGCodes = isWordInCardsName(card, 'gcodes'); + final isApple = isWordInCardsName(card, 'itunes') || isWordInCardsName(card, 'apple'); + final isTidal = isWordInCardsName(card, 'tidal'); + final isVPNServices = isWordInCardsName(card, 'nordvpn') || + isWordInCardsName(card, 'expressvpn') || + isWordInCardsName(card, 'surfshark') || + isWordInCardsName(card, 'proton'); + final isStreamingServices = isWordInCardsName(card, 'netflix') || + isWordInCardsName(card, 'spotify') || + isWordInCardsName(card, 'hulu') || + isWordInCardsName(card, 'hbo') || + isWordInCardsName(card, 'soundcloud') || + isWordInCardsName(card, 'twitch'); + final isDatingServices = isWordInCardsName(card, 'tinder'); + + return isDigitalGameStores || + isGCodes || + isApple || + isTidal || + isVPNServices || + isStreamingServices || + isDatingServices; + } + + Future alertIOSAvailability(BuildContext context, CakePayCard card) async { + cakePayBuyCardViewModel.isSimulatingFlow = false; + return await showPopUp( + context: context, + builder: (BuildContext context) { + return AlertWithOneAction( + alertTitle: S.of(context).error, + alertContent: S.of(context).cakepay_ios_not_available, + buttonText: S.of(context).ok, + buttonAction: () { + // _walletHardwareRestoreVM.error = null; + Navigator.of(context).pop(); + }); + }); + } + + Future confirmPurchaseFirst(BuildContext context) async { + bool isLogged = await cakePayBuyCardViewModel.cakePayService.isLogged(); + if (!isLogged) { + cakePayBuyCardViewModel.isSimulatingFlow = false; + Navigator.of(context).pushNamed(Routes.cakePayWelcomePage); + } else { + cakePayBuyCardViewModel.isPurchasing = true; + await _showconfirmPurchaseFirstAlert(context); + } + } + + Future _showconfirmPurchaseFirstAlert(BuildContext context) async { + if (!cakePayBuyCardViewModel.confirmsNoVpn || + !cakePayBuyCardViewModel.confirmsVoidedRefund || + !cakePayBuyCardViewModel.confirmsTermsAgreed) { + await showPopUp( + context: context, + builder: (BuildContext context) => ThreeCheckboxAlert( + alertTitle: S.of(context).cakepay_confirm_purchase, + leftButtonText: S.of(context).cancel, + rightButtonText: S.of(context).confirm, + actionLeftButton: () { + cakePayBuyCardViewModel.isPurchasing = false; + cakePayBuyCardViewModel.isSimulatingFlow = false; + Navigator.of(context).pop(); + }, + actionRightButton: (confirmsNoVpn, confirmsVoidedRefund, confirmsTermsAgreed) { + cakePayBuyCardViewModel.confirmsNoVpn = confirmsNoVpn; + cakePayBuyCardViewModel.confirmsVoidedRefund = confirmsVoidedRefund; + cakePayBuyCardViewModel.confirmsTermsAgreed = confirmsTermsAgreed; + + Navigator.of(context).pop(); + }, + ), + ); + } + + if (cakePayBuyCardViewModel.confirmsNoVpn && + cakePayBuyCardViewModel.confirmsVoidedRefund && + cakePayBuyCardViewModel.confirmsTermsAgreed) { + await purchaseCard(context); + } else { + cakePayBuyCardViewModel.isPurchasing = false; + cakePayBuyCardViewModel.isSimulatingFlow = false; + } + } + + Future purchaseCard(BuildContext context) async { + bool isLogged = await cakePayBuyCardViewModel.cakePayService.isLogged(); + if (!isLogged) { + cakePayBuyCardViewModel.isSimulatingFlow = false; + Navigator.of(context).pushNamed(Routes.cakePayWelcomePage); + } else { + try { + await cakePayBuyCardViewModel.createOrder(); + } catch (_) { + cakePayBuyCardViewModel.isSimulatingFlow = false; + await cakePayBuyCardViewModel.cakePayService.logout(); + } + } + cakePayBuyCardViewModel.isPurchasing = false; + } + + BuildContext? dialogContext; + BuildContext? loadingBottomSheetContext; + BuildContext? confirmBottomSheetContext; + + void _setEffects(BuildContext context) { + if (_effectsInstalled) { + return; + } + + _overlayCtx = Navigator.of(context).context; + + if (cakePayBuyCardViewModel.sendViewModel.isElectrumWallet) { + bitcoin!.updateFeeRates(cakePayBuyCardViewModel.sendViewModel.wallet); + } + + reaction((_) => cakePayBuyCardViewModel.sendViewModel.state, (ExecutionState state) async { + if (dialogContext != null && dialogContext!.mounted) Navigator.of(dialogContext!).pop(); + + if (confirmBottomSheetContext != null && confirmBottomSheetContext!.mounted) { + Navigator.of(confirmBottomSheetContext!).pop(); + } + + if (state is! IsExecutingState && + loadingBottomSheetContext != null && + loadingBottomSheetContext!.mounted) { + Navigator.of(loadingBottomSheetContext!).pop(); + } + + if (state is FailureState) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (context.mounted) + showPopUp( + context: context, + builder: (BuildContext context) { + return AlertWithOneAction( + key: ValueKey('cake_pay_buy_page_send_failure_dialog_key'), + buttonKey: ValueKey('cake_pay_buy_page_send_failure_dialog_button_key'), + alertTitle: S.of(context).error, + alertContent: state.error, + buttonText: S.of(context).ok, + buttonAction: () => Navigator.of(context).pop()); + }); + }); + } + + if (state is IsExecutingState) { + // wait a bit to avoid showing the loading dialog if transaction is failed + await Future.delayed(const Duration(milliseconds: 300)); + final currentState = cakePayBuyCardViewModel.sendViewModel.state; + if (currentState is ExecutedSuccessfullyState || currentState is FailureState) { + return; + } + + WidgetsBinding.instance.addPostFrameCallback((_) { + if (context.mounted) { + showModalBottomSheet( + context: context, + isDismissible: false, + builder: (BuildContext context) { + loadingBottomSheetContext = context; + return LoadingBottomSheet( + titleText: S.of(context).generating_transaction, + ); + }, + ); + } + }); + } + + if (state is ExecutedSuccessfullyState) { + if (cakePayBuyCardViewModel.order == null) return; + + ReactionDisposer? disposer; + + disposer = reaction((_) => cakePayBuyCardViewModel.isOrderExpired, (bool isExpired) { + if (isExpired) { + cakePayBuyCardViewModel.sendViewModel.state = FailureState('Order expired'); + disposer?.call(); + } + }); + + WidgetsBinding.instance.addPostFrameCallback((_) async { + if (context.mounted) { + final order = cakePayBuyCardViewModel.order; + + final displayingOutputs = cakePayBuyCardViewModel.sendViewModel.outputs + .map((o) => o.OutputCopyWithParsedAddress( + parsedAddress: ParsedAddress( + addresses: [o.address], + name: 'Cake Pay', + profileName: order?.cards.first.cardName ?? 'Cake Pay', + profileImageUrl: order?.cards.first.cardImagePath ?? '', + ), + fiatAmount: '${order?.amountUsd.toString()} USD', + )) + .toList(); + + final result = await showModalBottomSheet( + context: context, + isDismissible: false, + isScrollControlled: true, + builder: (BuildContext bottomSheetContext) { + confirmBottomSheetContext = bottomSheetContext; + return ConfirmSendingBottomSheet( + key: ValueKey('cake_pay_buy_page_confirm_sending_dialog_key'), + titleText: S.of(bottomSheetContext).confirm_transaction, + currentTheme: currentTheme, + cakePayBuyCardViewModel: cakePayBuyCardViewModel, + paymentId: S.of(bottomSheetContext).payment_id, + paymentIdValue: cakePayBuyCardViewModel.order?.orderId, + expirationTime: cakePayBuyCardViewModel.formattedRemainingTime, + walletType: cakePayBuyCardViewModel.sendViewModel.walletType, + titleIconPath: + cakePayBuyCardViewModel.sendViewModel.selectedCryptoCurrency.iconPath, + currency: cakePayBuyCardViewModel.sendViewModel.selectedCryptoCurrency, + amount: S.of(bottomSheetContext).send_amount, + amountValue: + cakePayBuyCardViewModel.sendViewModel.pendingTransaction!.amountFormatted, + quantity: 'QTY: ${cakePayBuyCardViewModel.quantity}', + fiatAmountValue: + cakePayBuyCardViewModel.sendViewModel.pendingTransactionFiatAmountFormatted, + fee: S.of(bottomSheetContext).send_fee, + feeValue: cakePayBuyCardViewModel.sendViewModel.pendingTransaction!.feeFormatted, + feeFiatAmount: cakePayBuyCardViewModel + .sendViewModel.pendingTransactionFeeFiatAmountFormatted, + outputs: displayingOutputs, + footerType: FooterType.slideActionButton, + slideActionButtonText: + cakePayBuyCardViewModel.isSimulating ? 'Swipe to simulate' : 'Swipe to send', + accessibleNavigationModeSlideActionButtonText: + cakePayBuyCardViewModel.isSimulating ? 'Simulate' : S.of(context).send, + onSlideActionComplete: () async { + Navigator.of(bottomSheetContext).pop(true); + cakePayBuyCardViewModel.isSimulating + ? cakePayBuyCardViewModel.simulatePayment() + : cakePayBuyCardViewModel.sendViewModel.commitTransaction(context); + }, + change: cakePayBuyCardViewModel.sendViewModel.pendingTransaction!.change, + isOpenCryptoPay: cakePayBuyCardViewModel.sendViewModel.ocpRequest != null, + ); + }, + ); + + confirmBottomSheetContext = null; + cakePayBuyCardViewModel.isSimulatingFlow = false; + _handleDispose(disposer); + if (result == null) cakePayBuyCardViewModel.sendViewModel.dismissTransaction(); + } + }); + } + + if (state is TransactionCommitted) { + final order = cakePayBuyCardViewModel.order; + final outputsCopy = List.from(cakePayBuyCardViewModel.sendViewModel.outputs); + + final displayingOutputs = outputsCopy + .map((o) => o.OutputCopyWithParsedAddress( + parsedAddress: ParsedAddress( + addresses: [o.address], + name: 'Cake Pay', + profileName: order?.cards.first.cardName ?? 'Cake Pay', + profileImageUrl: order?.cards.first.cardImagePath ?? '', + ), + fiatAmount: '${order?.amountUsd ?? 0} USD', + )) + .toList(); + + cakePayBuyCardViewModel.sendViewModel.clearOutputs(); + + final bool usePageContextLater = context.mounted; + + WidgetsBinding.instance.addPostFrameCallback((_) async { + if (displayingOutputs.isEmpty) { + if (context.mounted) Navigator.of(context).pop(); + return; + } + + final BuildContext sheetParentCtx = usePageContextLater ? context : _overlayCtx; + + await showModalBottomSheet( + context: sheetParentCtx, + useRootNavigator: !usePageContextLater, + isScrollControlled: true, + isDismissible: true, + backgroundColor: Colors.transparent, + builder: (sheetCtx) { + return CakePayTransactionSentBottomSheet( + key: const ValueKey('cake_pay_buy_page_transaction_sent_bottom_sheet_key'), + titleText: S.of(sheetCtx).transaction_sent, + titleIconWidget: const CircleAvatar( + radius: 10, + backgroundColor: Colors.green, + child: Icon(Icons.check, size: 16, color: Colors.white), + ), + output: displayingOutputs.first, + currency: cakePayBuyCardViewModel.sendViewModel.selectedCryptoCurrency, + amount: S.of(sheetCtx).send_amount, + amountValue: + cakePayBuyCardViewModel.sendViewModel.pendingTransaction!.amountFormatted, + quantity: 'QTY: ${cakePayBuyCardViewModel.quantity}', + fiatAmountValue: + cakePayBuyCardViewModel.sendViewModel.pendingTransactionFiatAmountFormatted, + fee: S.of(sheetCtx).send_fee, + feeValue: cakePayBuyCardViewModel.sendViewModel.pendingTransaction!.feeFormatted, + feeFiatAmount: + cakePayBuyCardViewModel.sendViewModel.pendingTransactionFeeFiatAmountFormatted, + paymentId: 'Order ID', + paymentIdValue: order?.orderId ?? '', + onClose: () { + Navigator.of(sheetCtx).pop(); + }, + ); + }, + ); + if (context.mounted) { + Navigator.of(context).pop(); + } + }); + } + + if (state is IsAwaitingDeviceResponseState) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!context.mounted) return; + + showModalBottomSheet( + context: context, + isDismissible: false, + builder: (BuildContext bottomSheetContext) => InfoBottomSheet( + currentTheme: currentTheme, + footerType: FooterType.singleActionButton, + titleText: S.of(bottomSheetContext).proceed_on_device, + contentImage: 'assets/images/hardware_wallet/ledger_nano_x.png', + contentImageColor: Theme.of(context).textTheme.titleLarge!.color!, + content: S.of(bottomSheetContext).proceed_on_device_description, + singleActionButtonText: S.of(context).cancel, + onSingleActionButtonPressed: () { + cakePayBuyCardViewModel.sendViewModel.state = InitialExecutionState(); + Navigator.of(bottomSheetContext).pop(); + }, + ), + ); + }); + } + }); + + _effectsInstalled = true; + } + + void _handleDispose(ReactionDisposer? disposer) { + cakePayBuyCardViewModel.dispose(); + if (disposer != null) { + disposer(); + } + } +} + +void _showHowToUseCard(BuildContext context, CakePayCard card) { + showPopUp( + context: context, + builder: (BuildContext context) { + return CakePayAlertModal( + title: S.of(context).how_to_use_card, + dismissible: true, + content: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Padding( + padding: EdgeInsets.all(10), + child: Text(card.name, style: Theme.of(context).textTheme.headlineSmall)), + ClickableLinksText( + text: card.howToUse ?? '', + textStyle: Theme.of(context).textTheme.bodyMedium!, + linkStyle: Theme.of(context).textTheme.bodyMedium!.copyWith( + color: Theme.of(context).colorScheme.primary, fontStyle: FontStyle.italic)) + ]), + actionTitle: S.current.got_it, + showCloseButton: false, + ); + }); +} + +void _showTermsAndCondition(BuildContext context, String? termsAndConditions) { + showPopUp( + context: context, + builder: (BuildContext context) { + return CakePayAlertModal( + title: S.of(context).settings_terms_and_conditions, + dismissible: true, + content: Align( + alignment: Alignment.bottomLeft, + child: ClickableLinksText( + text: termsAndConditions ?? '', + textStyle: Theme.of(context).textTheme.bodyMedium!, + linkStyle: Theme.of(context).textTheme.bodyMedium!.copyWith( + color: Theme.of(context).colorScheme.primary, fontStyle: FontStyle.italic))), + actionTitle: S.of(context).agree, + showCloseButton: false, + ); + }); +} diff --git a/lib/cake_pay/src/cards/cake_pay_cards_page.dart b/lib/cake_pay/src/cards/cake_pay_cards_page.dart new file mode 100644 index 000000000..63320e1c4 --- /dev/null +++ b/lib/cake_pay/src/cards/cake_pay_cards_page.dart @@ -0,0 +1,521 @@ +import 'package:cake_wallet/cake_pay/src/cake_pay_states.dart'; +import 'package:cake_wallet/cake_pay/src/models/cake_pay_card.dart'; +import 'package:cake_wallet/cake_pay/src/widgets/cake_pay_search_bar_widget.dart'; +import 'package:cake_wallet/cake_pay/src/widgets/user_card_item.dart'; +import 'package:cake_wallet/entities/country.dart'; +import 'package:cake_wallet/generated/i18n.dart'; +import 'package:cake_wallet/routes.dart'; +import 'package:cake_wallet/src/screens/base_page.dart'; +import 'package:cake_wallet/cake_pay/src/widgets/card_item.dart'; +import 'package:cake_wallet/src/screens/dashboard/widgets/filter_widget.dart'; +import 'package:cake_wallet/src/widgets/bottom_sheet/base_bottom_sheet_widget.dart'; +import 'package:cake_wallet/src/widgets/bottom_sheet/cake_pay_card_info_bottom_sheet_widget.dart'; +import 'package:cake_wallet/src/widgets/cake_scrollbar.dart'; +import 'package:cake_wallet/src/widgets/gradient_background.dart'; +import 'package:cake_wallet/src/widgets/picker.dart'; +import 'package:cake_wallet/src/widgets/tab_view_wrapper_widget.dart'; +import 'package:cake_wallet/themes/core/material_base_theme.dart'; +import 'package:cake_wallet/typography.dart'; +import 'package:cake_wallet/utils/debounce.dart'; +import 'package:cake_wallet/utils/feature_flag.dart'; +import 'package:cake_wallet/utils/responsive_layout_util.dart'; +import 'package:cake_wallet/utils/show_pop_up.dart'; +import 'package:cake_wallet/view_model/cake_pay/cake_pay_cards_list_view_model.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_mobx/flutter_mobx.dart'; +import 'package:mobx/mobx.dart'; + +class CakePayCardsPage extends BasePage { + CakePayCardsPage(this._cardsListViewModel); + + final CakePayCardsListViewModel _cardsListViewModel; + + @override + bool get gradientBackground => true; + + @override + Widget Function(BuildContext, Widget) get rootWrapper => + (BuildContext context, Widget scaffold) => GradientBackground(scaffold: scaffold); + + @override + bool get resizeToAvoidBottomInset => false; + + @override + Widget middle(BuildContext context) { + return Text( + 'Cake Pay', + style: textMediumSemiBold( + color: titleColor(context), + ), + ); + } + + @override + Widget trailing(BuildContext context) { + return Observer(builder: (_) { + final loggedIn = _cardsListViewModel.isUserAuthenticated == true; + + Future _handleOnPressed() async { + if (loggedIn) { + Navigator.pushNamed(context, Routes.cakePayAccountPage); + return; + } + final success = await Navigator.pushNamed(context, Routes.cakePayWelcomePage); + + if (success == true) await _cardsListViewModel.checkAuth(); + } + + if (!loggedIn || _cardsListViewModel.username == null) { + return _TrailingIcon( + asset: 'assets/images/profile.png', + iconColor: pageIconColor(context) ?? Colors.white, + onPressed: () async => await _handleOnPressed(), + ); + } + final letter = _cardsListViewModel.username!.trim()[0].toUpperCase(); + return IconButton( + padding: EdgeInsets.zero, + iconSize: 25, + onPressed: _handleOnPressed, + icon: CircleAvatar( + radius: 12, + backgroundColor: Theme.of(context).colorScheme.primary, + child: Text( + letter, + style: TextStyle( + color: Theme.of(context).colorScheme.onPrimary, + fontWeight: FontWeight.w600, + ), + ), + ), + ); + }); + } + + @override + Widget body(BuildContext context) { + return CakePayCardsPageBody( + cardsListViewModel: _cardsListViewModel, + currentTheme: currentTheme, + titleColor: titleColor); + } +} + +class CakePayCardsPageBody extends StatefulWidget { + const CakePayCardsPageBody({ + super.key, + required CakePayCardsListViewModel cardsListViewModel, + required this.currentTheme, + required this.titleColor, + }) : _cardsListViewModel = cardsListViewModel; + + final CakePayCardsListViewModel _cardsListViewModel; + final MaterialThemeBase currentTheme; + final Color? Function(BuildContext) titleColor; + + @override + State createState() => _CakePayCardsPageBodyState(); +} + +class _CakePayCardsPageBodyState extends State { + ReactionDisposer? _countryPickerDisposer; + + @override + void initState() { + super.initState(); + final viewModel = widget._cardsListViewModel; + + _countryPickerDisposer = when( + (_) => viewModel.shouldShowCountryPicker, + () async { + viewModel.storeInitialFilterStates(); + + WidgetsBinding.instance.addPostFrameCallback( + (_) async { + await showCountryPicker(context, viewModel); + if (viewModel.hasFiltersChanged) { + viewModel.resetLoadingNextPageState(); + viewModel.getVendors(); + } + viewModel.settingsStore.selectedCakePayCountry = viewModel.selectedCountry; + }, + ); + }, + ); + } + + @override + void dispose() { + _countryPickerDisposer?.call(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Observer(builder: (_) { + final isUserAuthenticated = widget._cardsListViewModel.isUserAuthenticated; + + if (isUserAuthenticated == null) return const _Loading(); + + if (isUserAuthenticated == false || !FeatureFlag.isCakePayRedemptionFlowEnabled) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 14), + child: _ShopTab(cardsListViewModel: widget._cardsListViewModel), + ); + } + + final titleColor = widget.titleColor(context); + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 14), + child: Column(children: [ + Expanded( + child: TabViewWrapper( + labelStyle: TextStyle( + color: titleColor, + fontFamily: 'Lato', + fontSize: 20, + fontWeight: FontWeight.w600), + unselectedLabelStyle: TextStyle( + color: titleColor?.withAlpha(150) ?? Colors.white70, + fontFamily: 'Lato', + fontSize: 20, + fontWeight: FontWeight.w400), + indicatorColor: titleColor, + tabs: const [ + Tab(text: 'My Cards'), + Tab(text: 'Shop') + ], + views: [ + _MyCardsTab( + cardsListViewModel: widget._cardsListViewModel, + currentTheme: widget.currentTheme), + _ShopTab(cardsListViewModel: widget._cardsListViewModel) + ]), + ) + ])); + }); + } +} + +Future showFilterWidget( + BuildContext context, CakePayCardsListViewModel cardsListViewModel) async { + return showPopUp( + context: context, + builder: (BuildContext context) { + return FilterWidget(filterItems: cardsListViewModel.createFilterItems); + }, + ); +} + +Future showCountryPicker( + BuildContext context, CakePayCardsListViewModel cardsListViewModel) async { + await showPopUp( + context: context, + builder: (_) => Picker( + title: S.of(context).select_your_country, + items: cardsListViewModel.availableCountries, + images: cardsListViewModel.availableCountries + .map((e) => Image.asset( + e.iconPath, + errorBuilder: (context, error, stackTrace) => Container( + width: 58, + height: 58, + ), + )) + .toList(), + selectedAtIndex: + cardsListViewModel.availableCountries.indexOf(cardsListViewModel.selectedCountry), + onItemSelected: (Country country) => cardsListViewModel.setSelectedCountry(country), + isSeparated: false, + hintText: S.of(context).search, + matchingCriteria: (Country country, String searchText) => + country.fullName.toLowerCase().contains(searchText.toLowerCase()))); +} + +class _TrailingIcon extends StatelessWidget { + const _TrailingIcon({required this.asset, this.onPressed, required this.iconColor}); + + final String asset; + final VoidCallback? onPressed; + final Color iconColor; + + @override + Widget build(BuildContext context) { + return Semantics( + label: S.of(context).profile, + child: Material( + color: Colors.transparent, + child: IconButton( + padding: EdgeInsets.zero, + constraints: BoxConstraints(), + highlightColor: Colors.transparent, + onPressed: onPressed, + icon: ImageIcon(AssetImage(asset), size: 25, color: iconColor), + ), + )); + } +} + +class _MyCardsTab extends StatefulWidget { + const _MyCardsTab({required this.cardsListViewModel, required this.currentTheme}); + + final CakePayCardsListViewModel cardsListViewModel; + final MaterialThemeBase currentTheme; + + @override + State<_MyCardsTab> createState() => _MyCardsTabState(); +} + +class _MyCardsTabState extends State<_MyCardsTab> { + late final TextEditingController _searchController; + + @override + void initState() { + super.initState(); + + _searchController = TextEditingController( + text: widget.cardsListViewModel.searchMyCardsString, + ); + } + + @override + void dispose() { + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final viewModel = widget.cardsListViewModel; + return Observer(builder: (_) { + return Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(2, 6, 0, 6), + child: CakePaySearchBar( + initialQuery: viewModel.searchMyCardsString, + controller: _searchController, + onSearch: viewModel.setMyCardsQuery, + onFilter: () async {}, // TODO: implement filter + )), + Expanded( + child: Observer(builder: (_) { + final cards = viewModel.filteredUserCards; + if (viewModel.userCardState is UserCakePayCardsStateFetching) return const _Loading(); + if (viewModel.userCardState is UserCakePayCardsStateNoCards) + return Expanded(child: Center(child: Text(S.of(context).no_cards_found))); + + final showThumb = cards.length > 6; + final userCardsList = Stack( + children: [ + GridView.builder( + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + childAspectRatio: 1.25, + crossAxisCount: responsiveLayoutUtil.shouldRenderTabletUI ? 3 : 2, + crossAxisSpacing: responsiveLayoutUtil.shouldRenderTabletUI ? 10 : 5, + mainAxisSpacing: responsiveLayoutUtil.shouldRenderTabletUI ? 10 : 5), + padding: EdgeInsets.only(left: 2, right: showThumb ? 10 : 22), + itemCount: cards.length, + itemBuilder: (_, i) { + final card = cards[i]; + return UserCardItem( + logoUrl: card.cardImageUrl, + title: card.name, + subTitle: '\$100', + onTap: () => _showCardInfoBottomSheet(context, card, widget.currentTheme), + ); + }, + ), + ], + ); + return showThumb ? Scrollbar( + key: ValueKey('cake_pay_my_cards_tab_scrollbar_key'), + thumbVisibility: true, + trackVisibility: true, + child: userCardsList, + ) : userCardsList; + }), + ), + ], + ); + }); + } +} + +Future _showCardInfoBottomSheet( + BuildContext context, CakePayCard card, MaterialThemeBase currentTheme) async { + bool isReloadable = false; // TODO: replace with real logic + if (card.name.toLowerCase().contains('prepaid')) { + isReloadable = true; + } + await showModalBottomSheet( + context: context, + isDismissible: false, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (BuildContext bottomSheetContext) { + return isReloadable + ? CakePayCardInfoBottomSheet( + isReloadable: isReloadable, + titleText: 'Reloadable Card', + balance: '100 USD', + howToUse: card.howToUse, + currentTheme: currentTheme, + footerType: FooterType.doubleActionButton, + applyBoxShadow: true, + contentImage: card.cardImageUrl, + leftActionButtonKey: const Key('cake_pay_cards_page_reload_card_left_button_key'), + doubleActionLeftButtonText: 'Archive', + onLeftActionButtonPressed: () {}, + rightActionButtonKey: const Key('cake_pay_cards_page_reload_card_right_button_key'), + doubleActionRightButtonText: 'Top Up', + onRightActionButtonPressed: () {}, + onUpdateBalancePressed: () {}) + : CakePayCardInfoBottomSheet( + isReloadable: isReloadable, + titleText: card.name, + balance: '500 USD', + howToUse: card.howToUse, + currentTheme: currentTheme, + footerType: FooterType.singleActionButton, + applyBoxShadow: true, + contentImage: card.cardImageUrl, + singleActionButtonKey: const Key('cake_pay_cards_page_card_info_bottom_sheet_key'), + singleActionButtonText: 'Mark As Used', + onSingleActionButtonPressed: () {}, + onUpdateBalancePressed: () {}); + }, + ); +} + +class _ShopTab extends StatefulWidget { + const _ShopTab({required this.cardsListViewModel}); + + final CakePayCardsListViewModel cardsListViewModel; + + @override + State<_ShopTab> createState() => _ShopTabState(); +} + +class _ShopTabState extends State<_ShopTab> { + late final ScrollController _scroll; + + + @override + void initState() { + super.initState(); + _scroll = ScrollController() + ..addListener(() { + if (!_scroll.hasClients) return; + final max = _scroll.position.maxScrollExtent; + + final threshold = 200.0; + if (_scroll.offset >= max - threshold && !_scroll.position.outOfRange) { + widget.cardsListViewModel.fetchNextPage(); + } + }); + } + + @override + void dispose() { + _scroll.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final viewModel = widget.cardsListViewModel; + + return Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(2, 6, 0, 6), + child: CakePaySearchBar( + initialQuery: viewModel.searchString, + onSearch: (String searchText) { + if (searchText != viewModel.searchString) { + viewModel.searchString = searchText; + viewModel.resetLoadingNextPageState(); + viewModel.getVendors(text: searchText); + } + }, + onFilter: () async { + viewModel.storeInitialFilterStates(); + await showFilterWidget(context, viewModel); + if (viewModel.hasFiltersChanged) { + viewModel.resetLoadingNextPageState(); + viewModel.getVendors(text: viewModel.searchString); + } + }, + onCountryPick: () async { + viewModel.storeInitialFilterStates(); + await showCountryPicker(context, viewModel); + if (viewModel.hasFiltersChanged) { + viewModel.resetLoadingNextPageState(); + viewModel.getVendors(text: viewModel.searchString); + } + }, + selectedCountry: viewModel.selectedCountry, + ), + ), + Expanded( + child: Observer(builder: (_) { + final vendors = viewModel.cakePayVendors; + + if (viewModel.vendorsState is! CakePayVendorLoadedState) { + return const _Loading(); + } + + if (vendors.isEmpty) + return Expanded(child: Center(child: Text(S.of(context).no_cards_found))); + + final loadingMore = viewModel.isLoadingNextPage; + final showThumb = vendors.length > 3; + final cardsList = Stack( + children: [ + GridView.builder( + controller: _scroll, + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: responsiveLayoutUtil.shouldRenderTabletUI ? 2 : 1, + childAspectRatio: 5, + crossAxisSpacing: responsiveLayoutUtil.shouldRenderTabletUI ? 10 : 5, + mainAxisSpacing: responsiveLayoutUtil.shouldRenderTabletUI ? 10 : 5), + padding: EdgeInsets.only(left: 2, right: showThumb ? 10 : 22), + itemCount: vendors.length + (loadingMore ? 1 : 0), + itemBuilder: (_, i) { + if (i >= vendors.length) return const _Loading(); + final vendor = vendors[i]; + return CardItem( + logoUrl: vendor.card?.cardImageUrl, + title: vendor.name, + subTitle: vendor.card?.description ?? '', + onTap: () => Navigator.pushNamed(context, Routes.cakePayBuyCardPage, + arguments: [vendor])); + }, + ), + ], + ); + return showThumb + ? Scrollbar( + key: ValueKey('cake_pay_shop_tab_scrollbar_key'), + thumbVisibility: true, + trackVisibility: true, + controller: _scroll, + child: cardsList, + ) + : cardsList; + }), + ), + ], + ); + } +} + +class _Loading extends StatelessWidget { + const _Loading(); + + @override + Widget build(BuildContext context) => Center( + child: CircularProgressIndicator(), + ); +} diff --git a/lib/cake_pay/cake_pay_card.dart b/lib/cake_pay/src/models/cake_pay_card.dart similarity index 100% rename from lib/cake_pay/cake_pay_card.dart rename to lib/cake_pay/src/models/cake_pay_card.dart diff --git a/lib/cake_pay/cake_pay_order.dart b/lib/cake_pay/src/models/cake_pay_order.dart similarity index 76% rename from lib/cake_pay/cake_pay_order.dart rename to lib/cake_pay/src/models/cake_pay_order.dart index acdf86ea0..52f11272e 100644 --- a/lib/cake_pay/cake_pay_order.dart +++ b/lib/cake_pay/src/models/cake_pay_order.dart @@ -1,3 +1,15 @@ +enum CakePayPaymentMethod { BTC, BTC_LN, XMR, LTC, LTC_MWEB } + +extension CakePayPaymentMethodLabel on CakePayPaymentMethod { + String get label => switch (this) { + CakePayPaymentMethod.BTC => 'Bitcoin', + CakePayPaymentMethod.BTC_LN => 'Bitcoin Lightning', + CakePayPaymentMethod.XMR => 'Monero', + CakePayPaymentMethod.LTC => 'Litecoin', + CakePayPaymentMethod.LTC_MWEB => 'Litecoin MWEB', + }; +} + class CakePayOrder { final String orderId; final List cards; @@ -37,6 +49,8 @@ class OrderCard { final String price; final int quantity; final String currencyCode; + final String? cardName; + final String? cardImagePath; OrderCard({ required this.cardId, @@ -44,6 +58,8 @@ class OrderCard { required this.price, required this.quantity, required this.currencyCode, + required this.cardName, + required this.cardImagePath, }); factory OrderCard.fromMap(Map map) { @@ -53,20 +69,28 @@ class OrderCard { price: map['price'] as String, quantity: map['quantity'] as int, currencyCode: map['currency_code'] as String, + cardName: map['name'] as String?, + cardImagePath: map['card_image_url'] as String?, ); } } class PaymentData { final CryptoPaymentData btc; + final CryptoPaymentData btc_ln; final CryptoPaymentData xmr; + final CryptoPaymentData ltc; + final CryptoPaymentData ltc_mweb; final DateTime invoiceTime; final DateTime expirationTime; final int? commission; PaymentData({ required this.btc, + required this.btc_ln, required this.xmr, + required this.ltc, + required this.ltc_mweb, required this.invoiceTime, required this.expirationTime, required this.commission, @@ -75,7 +99,10 @@ class PaymentData { factory PaymentData.fromMap(Map map) { return PaymentData( btc: CryptoPaymentData.fromMap(map['BTC'] as Map), + btc_ln: CryptoPaymentData.fromMap(map['BTC_LN'] as Map), xmr: CryptoPaymentData.fromMap(map['XMR'] as Map), + ltc: CryptoPaymentData.fromMap(map['LTC'] as Map), + ltc_mweb: CryptoPaymentData.fromMap(map['LTC_MWEB'] as Map), invoiceTime: DateTime.fromMillisecondsSinceEpoch(map['invoice_time'] as int), expirationTime: DateTime.fromMillisecondsSinceEpoch(map['expiration_time'] as int), commission: map['commission'] as int?, diff --git a/lib/cake_pay/cake_pay_user_credentials.dart b/lib/cake_pay/src/models/cake_pay_user_credentials.dart similarity index 100% rename from lib/cake_pay/cake_pay_user_credentials.dart rename to lib/cake_pay/src/models/cake_pay_user_credentials.dart diff --git a/lib/cake_pay/cake_pay_vendor.dart b/lib/cake_pay/src/models/cake_pay_vendor.dart similarity index 100% rename from lib/cake_pay/cake_pay_vendor.dart rename to lib/cake_pay/src/models/cake_pay_vendor.dart diff --git a/lib/cake_pay/cake_pay_api.dart b/lib/cake_pay/src/services/cake_pay_api.dart similarity index 85% rename from lib/cake_pay/cake_pay_api.dart rename to lib/cake_pay/src/services/cake_pay_api.dart index a3c7f48d0..c61a068f1 100644 --- a/lib/cake_pay/cake_pay_api.dart +++ b/lib/cake_pay/src/services/cake_pay_api.dart @@ -1,8 +1,8 @@ import 'dart:convert'; -import 'package:cake_wallet/cake_pay/cake_pay_order.dart'; -import 'package:cake_wallet/cake_pay/cake_pay_user_credentials.dart'; -import 'package:cake_wallet/cake_pay/cake_pay_vendor.dart'; +import 'package:cake_wallet/cake_pay/src/models/cake_pay_order.dart'; +import 'package:cake_wallet/cake_pay/src/models/cake_pay_user_credentials.dart'; +import 'package:cake_wallet/cake_pay/src/models/cake_pay_vendor.dart'; import 'package:cw_core/utils/proxy_wrapper.dart'; import 'package:cw_core/utils/print_verbose.dart'; import 'package:cake_wallet/entities/country.dart'; @@ -42,7 +42,6 @@ class CakePayApi { throw Exception('Unexpected http status: ${response.statusCode}'); } - final bodyJson = json.decode(response.body) as Map; if (bodyJson.containsKey('user') && bodyJson['user']['email'] != null) { @@ -79,7 +78,6 @@ class CakePayApi { throw Exception('Unexpected http status: ${response.statusCode}'); } - final bodyJson = json.decode(response.body) as Map; if (bodyJson.containsKey('error')) { @@ -108,12 +106,14 @@ class CakePayApi { required bool confirmsTermsAgreed, }) async { final uri = Uri.https(baseCakePayUri, createOrderPath); + final headers = { 'Accept': 'application/json', 'Content-Type': 'application/json', 'Authorization': 'Api-Key $apiKey', }; - final query = { + + final body = json.encode({ 'card_id': cardId, 'price': price, 'quantity': quantity, @@ -123,35 +123,42 @@ class CakePayApi { 'confirms_no_vpn': confirmsNoVpn, 'confirms_voided_refund': confirmsVoidedRefund, 'confirms_terms_agreed': confirmsTermsAgreed, - }; + }); - try { - final response = await ProxyWrapper().post( - clearnetUri: uri, - headers: headers, - body: json.encode(query), - ); + final response = await ProxyWrapper().post( + clearnetUri: uri, + headers: headers, + body: body, + ); - if (response.statusCode != 201) { - - final responseBody = json.decode(response.body); - if (responseBody is List) { - throw '${responseBody[0]}'; - } else { - throw Exception('Unexpected error: $responseBody'); - } - } - - - final bodyJson = json.decode(response.body) as Map; - return CakePayOrder.fromMap(bodyJson); - } catch (e) { - throw Exception('${e}'); + if (response.statusCode == 201) { + final data = json.decode(response.body) as Map; + return CakePayOrder.fromMap(data); } + + String message = 'Server error ${response.statusCode}'; + + final isJson = response.headers['content-type']?.contains('application/json') == true || + response.body.trim().startsWith(RegExp(r'[\{\[]')); + + if (isJson) { + try { + final decoded = json.decode(response.body); + if (decoded is List && decoded.isNotEmpty) { + message = decoded.first.toString(); + } else if (decoded is Map && decoded['detail'] != null) { + message = decoded['detail'].toString(); + } else { + message = decoded.toString(); + } + } on FormatException {} + } + + throw Exception(message); } ///Simulate Payment - Future simulatePayment( + Future simulatePayment( {required String CSRFToken, required String authorization, required String orderId}) async { final uri = Uri.https(baseCakePayUri, simulatePaymentPath + '/$orderId'); @@ -162,7 +169,6 @@ class CakePayApi { }; final response = await ProxyWrapper().get(clearnetUri: uri, headers: headers); - printV('Response: ${response.statusCode}'); @@ -172,7 +178,7 @@ class CakePayApi { final bodyJson = json.decode(response.body) as Map; - throw Exception('You just bot a gift card with id: ${bodyJson['order_id']}'); + return 'You just SIMULATED a buying of a gift card with ID: ${bodyJson['order_id']}'; } /// Logout @@ -209,7 +215,7 @@ class CakePayApi { }; final response = await ProxyWrapper().get(clearnetUri: uri, headers: headers); - + if (response.statusCode != 200) { throw Exception('Unexpected http status: ${response.statusCode}'); } @@ -256,7 +262,6 @@ class CakePayApi { }; var response = await ProxyWrapper().get(clearnetUri: uri, headers: headers); - if (response.statusCode != 200) { throw Exception( diff --git a/lib/cake_pay/cake_pay_service.dart b/lib/cake_pay/src/services/cake_pay_service.dart similarity index 92% rename from lib/cake_pay/cake_pay_service.dart rename to lib/cake_pay/src/services/cake_pay_service.dart index 9ba65df9a..d9c75b124 100644 --- a/lib/cake_pay/cake_pay_service.dart +++ b/lib/cake_pay/src/services/cake_pay_service.dart @@ -1,7 +1,7 @@ import 'package:cake_wallet/.secrets.g.dart' as secrets; -import 'package:cake_wallet/cake_pay/cake_pay_api.dart'; -import 'package:cake_wallet/cake_pay/cake_pay_order.dart'; -import 'package:cake_wallet/cake_pay/cake_pay_vendor.dart'; +import 'package:cake_wallet/cake_pay/src/services/cake_pay_api.dart'; +import 'package:cake_wallet/cake_pay/src/models/cake_pay_order.dart'; +import 'package:cake_wallet/cake_pay/src/models/cake_pay_vendor.dart'; import 'package:cake_wallet/core/secure_storage.dart'; import 'package:cake_wallet/entities/country.dart'; @@ -114,6 +114,6 @@ class CakePayService { } ///Simulate Purchase Gift Card - Future simulatePayment({required String orderId}) async => await cakePayApi.simulatePayment( + Future simulatePayment({required String orderId}) async => await cakePayApi.simulatePayment( CSRFToken: CSRFToken, authorization: authorization, orderId: orderId); } diff --git a/lib/cake_pay/src/widgets/cake_pay_alert_modal.dart b/lib/cake_pay/src/widgets/cake_pay_alert_modal.dart new file mode 100644 index 000000000..d24a31119 --- /dev/null +++ b/lib/cake_pay/src/widgets/cake_pay_alert_modal.dart @@ -0,0 +1,73 @@ +import 'package:cake_wallet/src/widgets/alert_background.dart'; +import 'package:cake_wallet/src/widgets/primary_button.dart'; +import 'package:flutter/material.dart'; + +class CakePayAlertModal extends StatelessWidget { + const CakePayAlertModal({ + super.key, + required this.title, + required this.content, + required this.actionTitle, + this.showCloseButton = true, + this.dismissible = false, + }); + + final String title; + final Widget content; + final String actionTitle; + final bool showCloseButton; + final bool dismissible; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final maxHeight = MediaQuery.of(context).size.height * 0.8; + + return AlertBackground( + dismissible: dismissible, + child: ConstrainedBox( + constraints: BoxConstraints(maxHeight: maxHeight), + child: Material( + color: Colors.transparent, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Container( + decoration: BoxDecoration( + color: theme.colorScheme.surface, borderRadius: BorderRadius.circular(30)), + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (title.isNotEmpty) ...[ + Text(title, + style: theme.textTheme.titleLarge?.copyWith( + color: theme.colorScheme.onSurface, fontWeight: FontWeight.bold), + textAlign: TextAlign.center), + const SizedBox(height: 12), + ], + Flexible(child: SingleChildScrollView(child: content)), + const SizedBox(height: 24), + PrimaryButton( + onPressed: () => Navigator.pop(context), + text: actionTitle, + color: theme.colorScheme.surfaceContainer, + textColor: theme.colorScheme.primary), + if (showCloseButton) ...[ + const SizedBox(height: 16), + InkWell( + onTap: () => Navigator.pop(context), + child: CircleAvatar( + backgroundColor: theme.colorScheme.surfaceContainer, + child: Icon(Icons.close, color: theme.colorScheme.onSurface), + ), + ), + ], + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/cake_pay/src/widgets/cake_pay_search_bar_widget.dart b/lib/cake_pay/src/widgets/cake_pay_search_bar_widget.dart new file mode 100644 index 000000000..3bbe5347d --- /dev/null +++ b/lib/cake_pay/src/widgets/cake_pay_search_bar_widget.dart @@ -0,0 +1,126 @@ +import 'package:cake_wallet/entities/country.dart'; +import 'package:cake_wallet/src/widgets/search_bar_widget.dart'; +import 'package:cake_wallet/utils/debounce.dart'; +import 'package:flutter/material.dart'; + +class CakePaySearchBar extends StatefulWidget { + const CakePaySearchBar( + {required this.initialQuery, + required this.onSearch, + required this.onFilter, + this.onCountryPick, + this.controller, + this.selectedCountry}); + + final String initialQuery; + final ValueChanged onSearch; + final VoidCallback onFilter; + final VoidCallback? onCountryPick; + final Country? selectedCountry; + final TextEditingController? controller; + + @override + State createState() => _CakePaySearchBarState(); +} + +class _CakePaySearchBarState extends State { + late final TextEditingController _searchController = + widget.controller ?? TextEditingController(text: widget.initialQuery); + final _searchFocusNode = FocusNode(); + final _debounce = Debounce(const Duration(milliseconds: 500)); + + @override + void initState() { + super.initState(); + _searchController + .addListener(() => _debounce.run(() => widget.onSearch(_searchController.text))); + } + + @override + void dispose() { + _searchController.dispose(); + _searchFocusNode.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 32, + child: Row( + children: [ + Expanded(child: SearchBarWidget(searchController: _searchController)), + const SizedBox(width: 5), + _CakePayFilterButton(onFilter: widget.onFilter), + if (widget.selectedCountry != null) + _CountryPickerWidget( + onTap: widget.onCountryPick, + selectedCountry: widget.selectedCountry!, + ), + ], + ), + ); + } +} + +class _CakePayFilterButton extends StatelessWidget { + const _CakePayFilterButton({required this.onFilter}); + + final VoidCallback onFilter; + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onFilter, + child: Container( + width: 32, + padding: const EdgeInsets.symmetric(vertical: 7), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainer, + borderRadius: BorderRadius.circular(10)), + child: Image.asset('assets/images/filter_icon.png', + color: Theme.of(context).colorScheme.onSurface)), + ); + } +} + +class _CountryPickerWidget extends StatelessWidget { + const _CountryPickerWidget({required this.selectedCountry, this.onTap}); + + final Country selectedCountry; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(left: 5), + child: GestureDetector( + onTap: onTap, + child: Container( + height: 32, + padding: const EdgeInsets.symmetric(horizontal: 8), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainer, + borderRadius: BorderRadius.circular(10)), + child: Row( + children: [ + Image.asset(selectedCountry.iconPath, + width: 24, + height: 24, + errorBuilder: (_, __, ___) => const SizedBox(width: 24, height: 24)), + const SizedBox(width: 6), + Text( + selectedCountry.countryCode, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w700, + color: Theme.of(context).colorScheme.onSurface, + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/src/screens/cake_pay/widgets/cake_pay_tile.dart b/lib/cake_pay/src/widgets/cake_pay_tile.dart similarity index 100% rename from lib/src/screens/cake_pay/widgets/cake_pay_tile.dart rename to lib/cake_pay/src/widgets/cake_pay_tile.dart diff --git a/lib/src/screens/cake_pay/widgets/card_item.dart b/lib/cake_pay/src/widgets/card_item.dart similarity index 57% rename from lib/src/screens/cake_pay/widgets/card_item.dart rename to lib/cake_pay/src/widgets/card_item.dart index 2794369c5..9b4659070 100644 --- a/lib/src/screens/cake_pay/widgets/card_item.dart +++ b/lib/cake_pay/src/widgets/card_item.dart @@ -1,18 +1,11 @@ import 'package:flutter/material.dart'; - import 'image_placeholder.dart'; class CardItem extends StatelessWidget { CardItem({ required this.title, required this.subTitle, - required this.backgroundColor, - required this.titleColor, - required this.subtitleColor, - this.hideBorder = true, - this.discount = 0.0, this.isAmount = false, - this.discountBackground, this.onTap, this.logoUrl, }); @@ -21,13 +14,7 @@ class CardItem extends StatelessWidget { final String title; final String subTitle; final String? logoUrl; - final double discount; final bool isAmount; - final bool hideBorder; - final Color backgroundColor; - final Color titleColor; - final Color subtitleColor; - final AssetImage? discountBackground; @override Widget build(BuildContext context) { @@ -40,13 +27,8 @@ class CardItem extends StatelessWidget { onTap: onTap, child: Container( decoration: BoxDecoration( - color: backgroundColor, + color: Theme.of(context).colorScheme.surface, borderRadius: BorderRadius.circular(10), - border: hideBorder - ? Border.all(color: Colors.transparent) - : Border.all( - color: Theme.of(context).colorScheme.outlineVariant.withOpacity(0.20), - ), ), child: Row( children: [ @@ -73,26 +55,20 @@ class CardItem extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodyMedium!.copyWith( - color: titleColor, - fontSize: 18, - fontWeight: FontWeight.w700, - ), - ), - Text( - subTitle, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodySmall!.copyWith( - color: titleColor, - fontSize: 10, - fontWeight: FontWeight.w700, - ), - ), + Text(title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context) + .textTheme + .bodyMedium! + .copyWith(fontSize: 18, fontWeight: FontWeight.w700)), + Text(subTitle, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context) + .textTheme + .bodySmall! + .copyWith(fontSize: 10, fontWeight: FontWeight.w700)), ], ), ), diff --git a/lib/cake_pay/src/widgets/denominations_amount_widget.dart b/lib/cake_pay/src/widgets/denominations_amount_widget.dart new file mode 100644 index 000000000..854c057ea --- /dev/null +++ b/lib/cake_pay/src/widgets/denominations_amount_widget.dart @@ -0,0 +1,122 @@ +import 'package:cake_wallet/generated/i18n.dart'; +import 'package:cake_wallet/src/widgets/number_text_fild_widget.dart'; +import 'package:cake_wallet/typography.dart'; +import 'package:cake_wallet/view_model/cake_pay/cake_pay_buy_card_view_model.dart'; +import 'package:cake_wallet/view_model/dashboard/dropdown_filter_item_widget.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_mobx/flutter_mobx.dart'; + +class DenominationsAmountWidget extends StatelessWidget { + const DenominationsAmountWidget( + {required this.fiatCurrency, + required this.denominations, + required this.amountFieldFocus, + required this.amountController, + required this.quantityFieldFocus, + required this.quantityController, + required this.cakePayBuyCardViewModel, + required this.onAmountChanged, + required this.onQuantityChanged}); + + final String fiatCurrency; + final List denominations; + final FocusNode amountFieldFocus; + final TextEditingController amountController; + final FocusNode quantityFieldFocus; + final TextEditingController quantityController; + final CakePayBuyCardViewModel cakePayBuyCardViewModel; + final Function(String) onAmountChanged; + final Function(int?) onQuantityChanged; + + @override + Widget build(BuildContext context) { + return Container( + height: MediaQuery.of(context).size.height * 0.1, + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + flex: 8, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + DropdownFilterList( + items: denominations, + itemPrefix: fiatCurrency, + selectedItem: denominations.first, + onItemSelected: (value) { + amountController.text = value; + onAmountChanged(value); + }), + const SizedBox(height: 4), + Container( + width: double.infinity, + decoration: BoxDecoration( + border: Border( + top: BorderSide( + width: 1.0, color: Theme.of(context).colorScheme.onSurfaceVariant)), + ), + child: Text(S.of(context).value, + maxLines: 2, + style: textSmall(color: Theme.of(context).colorScheme.onSurfaceVariant)), + ), + ], + ), + ), + Spacer(), + Expanded( + flex: 5, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + NumberTextField( + controller: quantityController, + focusNode: quantityFieldFocus, + min: 1, + max: 99, + onChanged: (value) => onQuantityChanged(value)), + const SizedBox(height: 4), + Container( + width: double.infinity, + decoration: BoxDecoration( + border: Border( + top: BorderSide( + width: 1.0, color: Theme.of(context).colorScheme.onSurfaceVariant), + ), + ), + child: Text(S.of(context).quantity, + maxLines: 1, + style: textSmall(color: Theme.of(context).colorScheme.onSurfaceVariant)), + ), + ], + ), + ), + Spacer(), + Expanded( + flex: 8, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Observer( + builder: (_) => Text('$fiatCurrency ${cakePayBuyCardViewModel.totalAmount}', + maxLines: 1, style: Theme.of(context).textTheme.titleMedium!)), + const SizedBox(height: 4), + Container( + width: double.infinity, + decoration: BoxDecoration( + border: Border( + top: BorderSide( + width: 1.0, color: Theme.of(context).colorScheme.onSurfaceVariant), + ), + ), + child: Text(S.of(context).total, + maxLines: 1, + style: textSmall(color: Theme.of(context).colorScheme.onSurfaceVariant)), + ), + ], + )), + ], + ), + ); + } +} diff --git a/lib/cake_pay/src/widgets/enter_amount_widget.dart b/lib/cake_pay/src/widgets/enter_amount_widget.dart new file mode 100644 index 000000000..082f05051 --- /dev/null +++ b/lib/cake_pay/src/widgets/enter_amount_widget.dart @@ -0,0 +1,103 @@ +import 'package:cake_wallet/generated/i18n.dart'; +import 'package:cake_wallet/src/widgets/base_text_form_field.dart'; +import 'package:cake_wallet/typography.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +class EnterAmountWidget extends StatelessWidget { + const EnterAmountWidget( + {required this.minValue, + required this.maxValue, + required this.fiatCurrency, + required this.amountFieldFocus, + required this.amountController, + required this.onAmountChanged}); + + final String minValue; + final String maxValue; + final String fiatCurrency; + final FocusNode amountFieldFocus; + final TextEditingController amountController; + final Function(String) onAmountChanged; + + @override + Widget build(BuildContext context) { + return Container( + height: MediaQuery.of(context).size.height * 0.1, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Spacer(flex: 1), + Text( + S.of(context).enter_amount, + style: Theme.of(context).textTheme.titleLarge!, + ), + Container( + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + width: 1.0, color: Theme.of(context).colorScheme.onSurfaceVariant))), + child: BaseTextFormField( + isDense: true, + contentPadding: EdgeInsets.zero, + controller: amountController, + keyboardType: TextInputType.numberWithOptions(signed: false, decimal: true), + hintText: '0.00', + maxLines: 1, + prefixIcon: Padding( + padding: const EdgeInsets.only(top: 2.0), + child: Text( + '$fiatCurrency: ', + style: Theme.of(context).textTheme.titleMedium!, + ), + ), + prefixIconConstraints: BoxConstraints(minWidth: 0, minHeight: 0), + suffixIconConstraints: BoxConstraints(minWidth: 0, minHeight: 0), + suffixIcon: Padding( + padding: const EdgeInsets.only(top: 2, right: 4), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text('QTY ', style: Theme.of(context).textTheme.titleMedium!), + Text( + '1', + style: Theme.of(context).textTheme + .titleMedium! + .copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant) + ), + ], + ), + ), + textStyle: Theme.of(context).textTheme.titleMedium!, + placeholderTextStyle: Theme.of(context) + .textTheme + .titleMedium! + .copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant), + inputFormatters: [ + FilteringTextInputFormatter.deny(RegExp('[\-|\ ]')), + FilteringTextInputFormatter.allow( + RegExp(r'^\d+(\.|\,)?\d{0,2}'), + ), + ], + ), + ), + const SizedBox(height: 4), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(S.of(context).min_amount(minValue) + ' $fiatCurrency', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + )), + Text(S.of(context).max_amount(maxValue) + ' $fiatCurrency', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + )), + ], + ), + Spacer(flex: 1), + ], + ), + ); + } +} diff --git a/lib/cake_pay/src/widgets/flip_card_widget.dart b/lib/cake_pay/src/widgets/flip_card_widget.dart new file mode 100644 index 000000000..83f65e83e --- /dev/null +++ b/lib/cake_pay/src/widgets/flip_card_widget.dart @@ -0,0 +1,62 @@ +import 'dart:math' as math; +import 'package:flutter/material.dart'; + +class FlipCard extends StatefulWidget { + const FlipCard({ + super.key, + required this.front, + required this.back, + this.duration = const Duration(milliseconds: 400), + this.flipOnTouch = true, + }); + + final Widget front; + final Widget back; + final Duration duration; + final bool flipOnTouch; + + @override + FlipCardState createState() => FlipCardState(); +} + +class FlipCardState extends State with SingleTickerProviderStateMixin { + late final AnimationController _ctrl = + AnimationController(vsync: this, duration: widget.duration); + bool _isFront = true; + + void toggleCard() { + if (_isFront) { + _ctrl.forward(); + } else { + _ctrl.reverse(); + } + _isFront = !_isFront; + } + + @override + Widget build(BuildContext context) { + final content = AnimatedBuilder( + animation: _ctrl, + builder: (_, __) { + final angle = _ctrl.value * math.pi; + final isFront = angle < math.pi / 2; + return Transform( + alignment: Alignment.center, + transform: Matrix4.identity() + ..setEntry(3, 2, 0.001) + ..rotateY(angle), + child: isFront ? widget.front + : Transform( + alignment: Alignment.center, + transform: Matrix4.rotationY(math.pi), + child: widget.back, + ), + ); + }, + ); + + return widget.flipOnTouch + ? GestureDetector(onTap: toggleCard, child: content) + : content; + } +} diff --git a/lib/src/screens/cake_pay/widgets/image_placeholder.dart b/lib/cake_pay/src/widgets/image_placeholder.dart similarity index 100% rename from lib/src/screens/cake_pay/widgets/image_placeholder.dart rename to lib/cake_pay/src/widgets/image_placeholder.dart diff --git a/lib/src/screens/cake_pay/widgets/link_extractor.dart b/lib/cake_pay/src/widgets/link_extractor.dart similarity index 100% rename from lib/src/screens/cake_pay/widgets/link_extractor.dart rename to lib/cake_pay/src/widgets/link_extractor.dart diff --git a/lib/cake_pay/src/widgets/rounded_overlay_cards_widget.dart b/lib/cake_pay/src/widgets/rounded_overlay_cards_widget.dart new file mode 100644 index 000000000..6a9889774 --- /dev/null +++ b/lib/cake_pay/src/widgets/rounded_overlay_cards_widget.dart @@ -0,0 +1,47 @@ +import 'package:flutter/material.dart'; + +class RoundedOverlayCards extends StatelessWidget { + const RoundedOverlayCards({ + this.topCardChild = const SizedBox(), + this.bottomCardChild = const SizedBox(), + }); + + final Widget topCardChild; + final Widget bottomCardChild; + + @override + Widget build(BuildContext context) { + final screenHeight = MediaQuery.of(context).size.height; + return ClipRRect( + borderRadius: + BorderRadius.only(bottomLeft: Radius.circular(25.0), bottomRight: Radius.circular(25.0)), + child: Container( + height: screenHeight * 0.50, + decoration: BoxDecoration( + borderRadius: BorderRadius.only( + bottomLeft: Radius.circular(24), + bottomRight: Radius.circular(24), + ), + color: Theme.of(context).colorScheme.surfaceContainer + ), + child: Column( + children: [ + ClipRRect( + borderRadius: BorderRadius.only( + bottomLeft: Radius.circular(25.0), bottomRight: Radius.circular(25.0)), + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.only( + bottomLeft: Radius.circular(24), bottomRight: Radius.circular(24)), + color: Theme.of(context).colorScheme.surfaceContainerLow, + ), + height: screenHeight * 0.38, + width: double.infinity, + child: topCardChild)), + bottomCardChild, + ], + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/src/screens/cake_pay/widgets/text_icon_button.dart b/lib/cake_pay/src/widgets/text_icon_button.dart similarity index 69% rename from lib/src/screens/cake_pay/widgets/text_icon_button.dart rename to lib/cake_pay/src/widgets/text_icon_button.dart index dac0088cb..6f912d6c7 100644 --- a/lib/src/screens/cake_pay/widgets/text_icon_button.dart +++ b/lib/cake_pay/src/widgets/text_icon_button.dart @@ -19,14 +19,15 @@ class TextIconButton extends StatelessWidget { children: [ Text( label, - style: Theme.of(context).textTheme.titleMedium?.copyWith( - color: Theme.of(context).colorScheme.onSurface, - fontWeight: FontWeight.w600, - ), + style: TextStyle( + color: Theme.of(context).textTheme.titleLarge!.color, + fontSize: 16, + fontWeight: FontWeight.w900, + ), ), Icon( Icons.chevron_right_rounded, - color: Theme.of(context).colorScheme.onSurface, + color: Theme.of(context).textTheme.titleLarge!.color, ), ], ), diff --git a/lib/cake_pay/src/widgets/three_checkbox_alert_content_widget.dart b/lib/cake_pay/src/widgets/three_checkbox_alert_content_widget.dart new file mode 100644 index 000000000..900bedf1b --- /dev/null +++ b/lib/cake_pay/src/widgets/three_checkbox_alert_content_widget.dart @@ -0,0 +1,203 @@ +import 'package:cake_wallet/generated/i18n.dart'; +import 'package:cake_wallet/src/widgets/base_alert_dialog.dart'; +import 'package:cake_wallet/src/widgets/standard_checkbox.dart'; +import 'package:flutter/material.dart'; +import 'package:url_launcher/url_launcher.dart'; + +class ThreeCheckboxAlert extends BaseAlertDialog { + ThreeCheckboxAlert({ + required this.alertTitle, + required this.leftButtonText, + required this.rightButtonText, + required this.actionLeftButton, + required this.actionRightButton, + this.alertBarrierDismissible = true, + Key? key, + }); + + final String alertTitle; + final String leftButtonText; + final String rightButtonText; + final VoidCallback actionLeftButton; + final Function(bool, bool, bool) actionRightButton; + final bool alertBarrierDismissible; + + bool checkbox1 = false; + void toggleCheckbox1() => checkbox1 = !checkbox1; + bool checkbox2 = false; + void toggleCheckbox2() => checkbox2 = !checkbox2; + bool checkbox3 = false; + void toggleCheckbox3() => checkbox3 = !checkbox3; + + bool showValidationMessage = true; + + @override + String get titleText => alertTitle; + + @override + bool get isDividerExists => true; + + @override + String get leftActionButtonText => leftButtonText; + + @override + String get rightActionButtonText => rightButtonText; + + @override + VoidCallback get actionLeft => actionLeftButton; + + @override + VoidCallback get actionRight => () { + actionRightButton(checkbox1, checkbox2, checkbox3); + }; + + @override + bool get barrierDismissible => alertBarrierDismissible; + + @override + Widget content(BuildContext context) { + return ThreeCheckboxAlertContent( + checkbox1: checkbox1, + toggleCheckbox1: toggleCheckbox1, + checkbox2: checkbox2, + toggleCheckbox2: toggleCheckbox2, + checkbox3: checkbox3, + toggleCheckbox3: toggleCheckbox3, + ); + } +} + +class ThreeCheckboxAlertContent extends StatefulWidget { + ThreeCheckboxAlertContent({ + required this.checkbox1, + required this.toggleCheckbox1, + required this.checkbox2, + required this.toggleCheckbox2, + required this.checkbox3, + required this.toggleCheckbox3, + Key? key, + }) : super(key: key); + + bool checkbox1; + void Function() toggleCheckbox1; + bool checkbox2; + void Function() toggleCheckbox2; + bool checkbox3; + void Function() toggleCheckbox3; + + @override + _ThreeCheckboxAlertContentState createState() => _ThreeCheckboxAlertContentState( + checkbox1: checkbox1, + toggleCheckbox1: toggleCheckbox1, + checkbox2: checkbox2, + toggleCheckbox2: toggleCheckbox2, + checkbox3: checkbox3, + toggleCheckbox3: toggleCheckbox3, + ); + + static _ThreeCheckboxAlertContentState? of(BuildContext context) { + return context.findAncestorStateOfType<_ThreeCheckboxAlertContentState>(); + } +} + +class _ThreeCheckboxAlertContentState extends State { + _ThreeCheckboxAlertContentState({ + required this.checkbox1, + required this.toggleCheckbox1, + required this.checkbox2, + required this.toggleCheckbox2, + required this.checkbox3, + required this.toggleCheckbox3, + }); + + bool checkbox1; + void Function() toggleCheckbox1; + bool checkbox2; + void Function() toggleCheckbox2; + bool checkbox3; + void Function() toggleCheckbox3; + + bool showValidationMessage = true; + + bool get areAllCheckboxesChecked => checkbox1 && checkbox2 && checkbox3; + + @override + Widget build(BuildContext context) { + return Form( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + StandardCheckbox( + value: checkbox1, + caption: S.of(context).cakepay_confirm_no_vpn, + onChanged: (bool? value) { + setState(() { + checkbox1 = value ?? false; + toggleCheckbox1(); + showValidationMessage = !areAllCheckboxesChecked; + }); + }, + ), + StandardCheckbox( + value: checkbox2, + caption: S.of(context).cakepay_confirm_voided_refund, + onChanged: (bool? value) { + setState(() { + checkbox2 = value ?? false; + toggleCheckbox2(); + showValidationMessage = !areAllCheckboxesChecked; + }); + }, + ), + StandardCheckbox( + value: checkbox3, + caption: S.of(context).cakepay_confirm_terms_agreed, + onChanged: (bool? value) { + setState(() { + checkbox3 = value ?? false; + toggleCheckbox3(); + showValidationMessage = !areAllCheckboxesChecked; + }); + }, + ), + GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => launchUrl( + Uri.parse("https://cakepay.com/cakepay-web-terms.txt"), + mode: LaunchMode.externalApplication, + ), + child: Padding( + padding: const EdgeInsets.only(top: 8.0), + child: Text( + S.of(context).settings_terms_and_conditions, + style: TextStyle( + fontSize: 16, + fontFamily: 'Lato', + fontWeight: FontWeight.w400, + color: Theme.of(context).primaryColor, + decoration: TextDecoration.none, + height: 1, + ), + softWrap: true, + ), + ), + ), + if (showValidationMessage) + Padding( + padding: const EdgeInsets.only(top: 8.0), + child: Text( + 'Please confirm all checkboxes', + style: TextStyle( + color: Colors.red, + fontSize: 14, + fontFamily: 'Lato', + fontWeight: FontWeight.w400, + decoration: TextDecoration.none, + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/cake_pay/src/widgets/user_card_item.dart b/lib/cake_pay/src/widgets/user_card_item.dart new file mode 100644 index 000000000..70ae447fe --- /dev/null +++ b/lib/cake_pay/src/widgets/user_card_item.dart @@ -0,0 +1,83 @@ +import 'package:flutter/material.dart'; +import 'image_placeholder.dart'; + +class UserCardItem extends StatelessWidget { + UserCardItem({ + required this.title, + required this.subTitle, + this.onTap, + this.logoUrl + }); + + final VoidCallback? onTap; + final String title; + final String subTitle; + final String? logoUrl; + + @override + Widget build(BuildContext context) { + return Theme( + data: ThemeData( + splashColor: Colors.transparent, + highlightColor: Colors.transparent, + ), + child: InkWell( + onTap: onTap, + child: Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: BorderRadius.circular(15), + ), + child: Column( + children: [ + if (logoUrl != null) + Padding( + padding: const EdgeInsets.all(2.0), + child: AspectRatio( + aspectRatio: 1.65, + child: ClipRRect( + borderRadius: BorderRadius.all(Radius.circular(13)), + child: Image.network( + logoUrl!, + fit: BoxFit.cover, + loadingBuilder: + (BuildContext context, Widget child, ImageChunkEvent? loadingProgress) { + if (loadingProgress == null) return child; + return Center(child: CircularProgressIndicator()); + }, + errorBuilder: (context, error, stackTrace) => CakePayCardImagePlaceholder(), + ), + ), + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + child: Row( + children: [ + Expanded( + child: Text(title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context) + .textTheme + .bodyMedium! + .copyWith(fontSize: 16, fontWeight: FontWeight.w700)), + ), + SizedBox(width: 8), + Text(subTitle, + maxLines: 1, + overflow: TextOverflow.clip, + style: Theme.of(context) + .textTheme + .bodyMedium! + .copyWith(fontSize: 16, fontWeight: FontWeight.w700)), + ], + ), + ), + ], + ), + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/di.dart b/lib/di.dart index 3dc417d46..6a7c118ff 100644 --- a/lib/di.dart +++ b/lib/di.dart @@ -76,7 +76,7 @@ import 'package:cake_wallet/entities/qr_view_data.dart'; import 'package:cake_wallet/entities/template.dart'; import 'package:cake_wallet/entities/transaction_description.dart'; import 'package:cake_wallet/ethereum/ethereum.dart'; -import 'package:cake_wallet/cake_pay/cake_pay_card.dart'; +import 'package:cake_wallet/cake_pay/src/models/cake_pay_card.dart'; import 'package:cake_wallet/exchange/exchange_template.dart'; import 'package:cake_wallet/exchange/trade.dart'; import 'package:cake_wallet/monero/monero.dart'; @@ -170,14 +170,12 @@ import 'package:cake_wallet/view_model/dashboard/nft_view_model.dart'; import 'package:cake_wallet/view_model/dashboard/receive_option_view_model.dart'; import 'package:cake_wallet/view_model/cake_pay/cake_pay_auth_view_model.dart'; import 'package:cake_wallet/view_model/cake_pay/cake_pay_buy_card_view_model.dart'; -import 'package:cake_wallet/cake_pay/cake_pay_service.dart'; -import 'package:cake_wallet/cake_pay/cake_pay_api.dart'; -import 'package:cake_wallet/cake_pay/cake_pay_vendor.dart'; -import 'package:cake_wallet/src/screens/cake_pay/auth/cake_pay_account_page.dart'; -import 'package:cake_wallet/src/screens/cake_pay/cake_pay.dart'; +import 'package:cake_wallet/cake_pay/src/services/cake_pay_service.dart'; +import 'package:cake_wallet/cake_pay/src/services/cake_pay_api.dart'; +import 'package:cake_wallet/cake_pay/src/models/cake_pay_vendor.dart'; +import 'package:cake_wallet/cake_pay/cake_pay.dart'; import 'package:cake_wallet/view_model/cake_pay/cake_pay_account_view_model.dart'; import 'package:cake_wallet/view_model/cake_pay/cake_pay_cards_list_view_model.dart'; -import 'package:cake_wallet/view_model/cake_pay/cake_pay_purchase_view_model.dart'; import 'package:cake_wallet/view_model/nano_account_list/nano_account_edit_or_create_view_model.dart'; import 'package:cake_wallet/view_model/nano_account_list/nano_account_list_view_model.dart'; import 'package:cake_wallet/view_model/new_wallet_type_view_model.dart'; @@ -276,7 +274,6 @@ import 'package:shared_preferences/shared_preferences.dart'; import 'buy/kryptonim/kryptonim.dart'; import 'buy/meld/meld_buy_provider.dart'; import 'src/screens/buy/buy_sell_page.dart'; -import 'cake_pay/cake_pay_payment_credantials.dart'; import 'package:cake_wallet/view_model/dev/background_sync_logs_view_model.dart'; import 'package:cake_wallet/src/screens/dev/background_sync_logs_page.dart'; import 'package:cake_wallet/core/trade_monitor.dart'; @@ -1390,18 +1387,11 @@ Future setup({ getIt.registerFactory(() => CakePayAuthViewModel(cakePayService: getIt.get())); - getIt.registerFactoryParam( - (PaymentCredential paymentCredential, CakePayCard card) { - return CakePayPurchaseViewModel( - cakePayService: getIt.get(), - paymentCredential: paymentCredential, - card: card, - sendViewModel: getIt.get()); - }); - getIt.registerFactoryParam( (CakePayVendor vendor, _) { - return CakePayBuyCardViewModel(vendor: vendor); + return CakePayBuyCardViewModel(vendor: vendor, + cakePayService: getIt.get(), + sendViewModel: getIt.get()); }); getIt.registerFactory(() => CakePayAccountViewModel(cakePayService: getIt.get())); @@ -1422,14 +1412,6 @@ Future setup({ getIt.get(param1: vendor), getIt.get()); }); - getIt - .registerFactoryParam, void>((List args, _) { - final paymentCredential = args.first as PaymentCredential; - final card = args[1] as CakePayCard; - return CakePayBuyCardDetailPage( - getIt.get(param1: paymentCredential, param2: card)); - }); - getIt.registerFactory(() => CakePayCardsPage(getIt.get())); getIt.registerFactory(() => CakePayAccountPage(getIt.get())); diff --git a/lib/router.dart b/lib/router.dart index 0df583362..1d9691fa3 100644 --- a/lib/router.dart +++ b/lib/router.dart @@ -21,8 +21,7 @@ import 'package:cake_wallet/src/screens/buy/buy_sell_options_page.dart'; import 'package:cake_wallet/src/screens/buy/buy_webview_page.dart'; import 'package:cake_wallet/src/screens/buy/payment_method_options_page.dart'; import 'package:cake_wallet/src/screens/buy/webview_page.dart'; -import 'package:cake_wallet/src/screens/cake_pay/auth/cake_pay_account_page.dart'; -import 'package:cake_wallet/src/screens/cake_pay/cake_pay.dart'; +import 'package:cake_wallet/cake_pay/cake_pay.dart'; import 'package:cake_wallet/src/screens/connect_device/connect_device_page.dart'; import 'package:cake_wallet/src/screens/connect_device/monero_hardware_wallet_options_page.dart'; import 'package:cake_wallet/src/screens/connect_device/select_hardware_wallet_account_page.dart'; @@ -153,14 +152,14 @@ import 'src/screens/dashboard/pages/nft_import_page.dart'; late RouteSettings currentRouteSettings; -Route handleRouteWithPlatformAwareness( +Route handleRouteWithPlatformAwareness( Widget Function(BuildContext) builder, { bool fullscreenDialog = false, }) { if (Platform.isIOS) { - return CupertinoPageRoute(builder: builder, fullscreenDialog: fullscreenDialog); + return CupertinoPageRoute(builder: builder, fullscreenDialog: fullscreenDialog); } else { - return MaterialPageRoute(builder: builder, fullscreenDialog: fullscreenDialog); + return MaterialPageRoute(builder: builder, fullscreenDialog: fullscreenDialog); } } @@ -708,25 +707,20 @@ Route createRoute(RouteSettings settings) { (context) => getIt.get(param1: args), ); - case Routes.cakePayBuyCardDetailPage: - final args = settings.arguments as List; - return handleRouteWithPlatformAwareness( - (context) => getIt.get(param1: args), - ); - case Routes.cakePayWelcomePage: - return handleRouteWithPlatformAwareness( + return handleRouteWithPlatformAwareness( (context) => getIt.get(), ); case Routes.cakePayVerifyOtpPage: final args = settings.arguments as List; - return handleRouteWithPlatformAwareness( + return handleRouteWithPlatformAwareness( (context) => getIt.get(param1: args), ); + case Routes.cakePayAccountPage: - return handleRouteWithPlatformAwareness( + return handleRouteWithPlatformAwareness( (context) => getIt.get(), ); diff --git a/lib/routes.dart b/lib/routes.dart index e18dc07b5..8d37becfa 100644 --- a/lib/routes.dart +++ b/lib/routes.dart @@ -75,7 +75,6 @@ class Routes { static const cakePayLoginPage = '/cake_pay_login_page'; static const cakePayCardsPage = '/cake_pay_cards_page'; static const cakePayBuyCardPage = '/cake_pay_buy_card_page'; - static const cakePayBuyCardDetailPage = '/cake_pay_buy_card_detail_page'; static const cakePayVerifyOtpPage = '/cake_pay_verify_otp_page'; static const cakePayAccountPage = '/cake_pay_account_page'; static const webViewPage = '/web_view_page'; diff --git a/lib/src/screens/cake_pay/cake_pay.dart b/lib/src/screens/cake_pay/cake_pay.dart deleted file mode 100644 index a65b6bed3..000000000 --- a/lib/src/screens/cake_pay/cake_pay.dart +++ /dev/null @@ -1,5 +0,0 @@ -export 'auth/cake_pay_welcome_page.dart'; -export 'auth/cake_pay_verify_otp_page.dart'; -export 'cards/cake_pay_confirm_purchase_card_page.dart'; -export 'cards/cake_pay_cards_page.dart'; -export 'cards/cake_pay_buy_card_page.dart'; diff --git a/lib/src/screens/cake_pay/cards/cake_pay_buy_card_page.dart b/lib/src/screens/cake_pay/cards/cake_pay_buy_card_page.dart deleted file mode 100644 index 99ecd63f8..000000000 --- a/lib/src/screens/cake_pay/cards/cake_pay_buy_card_page.dart +++ /dev/null @@ -1,519 +0,0 @@ -import 'dart:io'; - -import 'package:auto_size_text/auto_size_text.dart'; -import 'package:cake_wallet/cake_pay/cake_pay_card.dart'; -import 'package:cake_wallet/cake_pay/cake_pay_payment_credantials.dart'; -import 'package:cake_wallet/cake_pay/cake_pay_service.dart'; -import 'package:cake_wallet/generated/i18n.dart'; -import 'package:cake_wallet/routes.dart'; -import 'package:cake_wallet/src/screens/base_page.dart'; -import 'package:cake_wallet/src/screens/cake_pay/widgets/image_placeholder.dart'; -import 'package:cake_wallet/src/screens/cake_pay/widgets/link_extractor.dart'; -import 'package:cake_wallet/src/widgets/alert_with_one_action.dart'; -import 'package:cake_wallet/src/widgets/base_text_form_field.dart'; -import 'package:cake_wallet/src/widgets/keyboard_done_button.dart'; -import 'package:cake_wallet/src/widgets/number_text_fild_widget.dart'; -import 'package:cake_wallet/src/widgets/primary_button.dart'; -import 'package:cake_wallet/src/widgets/scollable_with_bottom_section.dart'; -import 'package:cake_wallet/typography.dart'; -import 'package:cake_wallet/utils/responsive_layout_util.dart'; -import 'package:cake_wallet/utils/show_pop_up.dart'; -import 'package:cake_wallet/view_model/cake_pay/cake_pay_buy_card_view_model.dart'; -import 'package:cake_wallet/view_model/dashboard/dropdown_filter_item_widget.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_mobx/flutter_mobx.dart'; -import 'package:keyboard_actions/keyboard_actions.dart'; - -class CakePayBuyCardPage extends BasePage { - CakePayBuyCardPage( - this.cakePayBuyCardViewModel, - this.cakePayService, - ) : _amountFieldFocus = FocusNode(), - _amountController = TextEditingController(), - _quantityFieldFocus = FocusNode(), - _quantityController = - TextEditingController(text: cakePayBuyCardViewModel.quantity.toString()) { - _amountController.addListener(() { - cakePayBuyCardViewModel.onAmountChanged(_amountController.text); - }); - } - - final CakePayBuyCardViewModel cakePayBuyCardViewModel; - final CakePayService cakePayService; - - @override - String get title => cakePayBuyCardViewModel.card.name; - - @override - bool get extendBodyBehindAppBar => true; - - @override - AppBarStyle get appBarStyle => AppBarStyle.completelyTransparent; - - @override - Widget? middle(BuildContext context) { - return Text( - title, - textAlign: TextAlign.center, - maxLines: 2, - style: Theme.of(context).textTheme.titleLarge?.copyWith( - color: Theme.of(context).colorScheme.onSurface, - fontWeight: FontWeight.w600, - ), - ); - } - - final TextEditingController _amountController; - final FocusNode _amountFieldFocus; - final TextEditingController _quantityController; - final FocusNode _quantityFieldFocus; - - @override - Widget body(BuildContext context) { - final card = cakePayBuyCardViewModel.card; - final vendor = cakePayBuyCardViewModel.vendor; - - return KeyboardActions( - disableScroll: true, - config: KeyboardActionsConfig( - keyboardActionsPlatform: KeyboardActionsPlatform.IOS, - keyboardBarColor: Theme.of(context).colorScheme.surface, - nextFocus: false, - actions: [ - KeyboardActionsItem( - focusNode: _amountFieldFocus, - toolbarButtons: [(_) => KeyboardDoneButton()], - ), - ]), - child: Container( - color: Theme.of(context).colorScheme.surface, - child: ScrollableWithBottomSection( - contentPadding: EdgeInsets.zero, - content: Column( - children: [ - ClipRRect( - borderRadius: BorderRadius.only( - bottomLeft: Radius.circular(25.0), bottomRight: Radius.circular(25.0)), - child: Container( - decoration: BoxDecoration( - gradient: LinearGradient( - colors: [ - Theme.of(context).colorScheme.primary, - Theme.of(context).colorScheme.secondary, - ], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - ), - height: responsiveLayoutUtil.screenHeight * 0.35, - width: double.infinity, - child: Column( - children: [ - Expanded(flex: 4, child: const SizedBox()), - Expanded( - flex: 7, - child: ClipRRect( - borderRadius: BorderRadius.all(Radius.circular(10)), - child: Image.network( - card.cardImageUrl ?? '', - fit: BoxFit.cover, - loadingBuilder: (BuildContext context, Widget child, - ImageChunkEvent? loadingProgress) { - if (loadingProgress == null) return child; - return Center(child: CircularProgressIndicator()); - }, - errorBuilder: (context, error, stackTrace) => - CakePayCardImagePlaceholder(), - ), - ), - ), - Expanded(child: const SizedBox()), - ], - )), - ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 24), - child: Container( - height: responsiveLayoutUtil.screenHeight * 0.5, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SizedBox(height: 24), - Expanded( - child: Text( - S.of(context).enter_amount, - style: Theme.of(context).textTheme.bodyLarge!.copyWith( - fontSize: 24, - fontWeight: FontWeight.w600, - ), - ), - ), - card.denominations.isNotEmpty - ? Expanded( - flex: 2, - child: _DenominationsAmountWidget( - fiatCurrency: card.fiatCurrency.title, - denominations: card.denominations, - amountFieldFocus: _amountFieldFocus, - amountController: _amountController, - quantityFieldFocus: _quantityFieldFocus, - quantityController: _quantityController, - onAmountChanged: cakePayBuyCardViewModel.onAmountChanged, - onQuantityChanged: cakePayBuyCardViewModel.onQuantityChanged, - cakePayBuyCardViewModel: cakePayBuyCardViewModel, - ), - ) - : Expanded( - flex: 2, - child: _EnterAmountWidget( - minValue: card.minValue ?? '-', - maxValue: card.maxValue ?? '-', - fiatCurrency: card.fiatCurrency.title, - amountFieldFocus: _amountFieldFocus, - amountController: _amountController, - onAmountChanged: cakePayBuyCardViewModel.onAmountChanged, - ), - ), - Expanded( - flex: 5, - child: Column( - children: [ - if (vendor.cakeWarnings != null) - Padding( - padding: const EdgeInsets.only(bottom: 8.0), - child: Container( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.primary, - borderRadius: BorderRadius.circular(10), - border: Border.all( - color: - Theme.of(context).colorScheme.onPrimary.withOpacity(0.20), - ), - ), - child: Padding( - padding: const EdgeInsets.all(8.0), - child: Text( - vendor.cakeWarnings!, - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.onPrimary, - fontWeight: FontWeight.w600, - ), - ), - ), - ), - ), - Expanded( - child: SingleChildScrollView( - child: ClickableLinksText( - text: card.description ?? '', - textStyle: Theme.of(context).textTheme.bodyMedium!.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - fontSize: 18, - ), - ), - ), - ), - ], - ), - ), - ], - ), - ), - ), - ], - ), - bottomSection: Column( - children: [ - Observer(builder: (_) { - return Padding( - padding: EdgeInsets.only(bottom: 12), - child: PrimaryButton( - onPressed: () => isIOSUnavailable(card) - ? alertIOSAvailability(context, card) - : navigateToCakePayBuyCardDetailPage(context, card), - text: S.of(context).buy_now, - isDisabled: !cakePayBuyCardViewModel.isEnablePurchase, - color: Theme.of(context).colorScheme.primary, - textColor: Theme.of(context).colorScheme.onPrimary, - ), - ); - }), - ], - ), - ), - ), - ); - } - - bool isWordInCardsName(CakePayCard card, String word) { - return card.name.toLowerCase().contains(word.toLowerCase()); - } - - bool isIOSUnavailable(CakePayCard card) { - if (!Platform.isIOS && !Platform.isMacOS) { - return false; - } - - final isDigitalGameStores = isWordInCardsName(card, 'playstation') || - isWordInCardsName(card, 'xbox') || - isWordInCardsName(card, 'steam') || - isWordInCardsName(card, 'meta quest') || - isWordInCardsName(card, 'kigso') || - isWordInCardsName(card, 'game world') || - isWordInCardsName(card, 'google') || - isWordInCardsName(card, 'nintendo'); - final isGCodes = isWordInCardsName(card, 'gcodes'); - final isApple = isWordInCardsName(card, 'itunes') || isWordInCardsName(card, 'apple'); - final isTidal = isWordInCardsName(card, 'tidal'); - final isVPNServices = isWordInCardsName(card, 'nordvpn') || - isWordInCardsName(card, 'expressvpn') || - isWordInCardsName(card, 'surfshark') || - isWordInCardsName(card, 'proton'); - final isStreamingServices = isWordInCardsName(card, 'netflix') || - isWordInCardsName(card, 'spotify') || - isWordInCardsName(card, 'hulu') || - isWordInCardsName(card, 'hbo') || - isWordInCardsName(card, 'soundcloud') || - isWordInCardsName(card, 'twitch'); - final isDatingServices = isWordInCardsName(card, 'tinder'); - - return isDigitalGameStores || - isGCodes || - isApple || - isTidal || - isVPNServices || - isStreamingServices || - isDatingServices; - } - - Future alertIOSAvailability(BuildContext context, CakePayCard card) async { - return await showPopUp( - context: context, - builder: (BuildContext context) { - return AlertWithOneAction( - alertTitle: S.of(context).error, - alertContent: S.of(context).cakepay_ios_not_available, - buttonText: S.of(context).ok, - buttonAction: () { - // _walletHardwareRestoreVM.error = null; - Navigator.of(context).pop(); - }); - }); - } - - Future navigateToCakePayBuyCardDetailPage(BuildContext context, CakePayCard card) async { - final userName = await cakePayService.getUserEmail(); - final paymentCredential = PaymentCredential( - amount: cakePayBuyCardViewModel.amount, - quantity: cakePayBuyCardViewModel.quantity, - totalAmount: cakePayBuyCardViewModel.totalAmount, - userName: userName, - fiatCurrency: card.fiatCurrency.title, - ); - - Navigator.pushNamed( - context, - Routes.cakePayBuyCardDetailPage, - arguments: [paymentCredential, card], - ); - } -} - -class _DenominationsAmountWidget extends StatelessWidget { - const _DenominationsAmountWidget({ - required this.fiatCurrency, - required this.denominations, - required this.amountFieldFocus, - required this.amountController, - required this.quantityFieldFocus, - required this.quantityController, - required this.cakePayBuyCardViewModel, - required this.onAmountChanged, - required this.onQuantityChanged, - }); - - final String fiatCurrency; - final List denominations; - final FocusNode amountFieldFocus; - final TextEditingController amountController; - final FocusNode quantityFieldFocus; - final TextEditingController quantityController; - final CakePayBuyCardViewModel cakePayBuyCardViewModel; - final Function(String) onAmountChanged; - final Function(int?) onQuantityChanged; - - @override - Widget build(BuildContext context) { - return Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - flex: 12, - child: Column( - children: [ - Expanded( - child: DropdownFilterList( - items: denominations, - itemPrefix: fiatCurrency, - selectedItem: denominations.first, - textStyle: textMediumSemiBold(color: Theme.of(context).colorScheme.onSurface), - onItemSelected: (value) { - amountController.text = value; - onAmountChanged(value); - }, - caption: '', - ), - ), - const SizedBox(height: 4), - Expanded( - child: Container( - width: double.infinity, - decoration: BoxDecoration( - border: Border( - top: BorderSide( - width: 1.0, color: Theme.of(context).colorScheme.onSurfaceVariant), - ), - ), - child: Text(S.of(context).choose_card_value + ':', - maxLines: 2, - style: textSmall(color: Theme.of(context).colorScheme.onSurfaceVariant)), - ), - ), - ], - ), - ), - Expanded(child: const SizedBox()), - Expanded( - flex: 8, - child: Column( - children: [ - Expanded( - child: NumberTextField( - controller: quantityController, - focusNode: quantityFieldFocus, - min: 1, - max: 99, - onChanged: (value) => onQuantityChanged(value), - ), - ), - const SizedBox(height: 4), - Expanded( - child: Container( - width: double.infinity, - decoration: BoxDecoration( - border: Border( - top: BorderSide( - width: 1.0, color: Theme.of(context).colorScheme.onSurfaceVariant), - ), - ), - child: Text(S.of(context).quantity + ':', - maxLines: 1, - style: textSmall(color: Theme.of(context).colorScheme.onSurfaceVariant)), - ), - ), - ], - ), - ), - Expanded(child: const SizedBox()), - Expanded( - flex: 12, - child: Column( - children: [ - Expanded( - child: Container( - alignment: Alignment.bottomCenter, - child: Observer( - builder: (_) => AutoSizeText( - '$fiatCurrency ${cakePayBuyCardViewModel.totalAmount}', - maxLines: 1, - style: textMediumSemiBold( - color: Theme.of(context).colorScheme.onSurface)))), - ), - const SizedBox(height: 4), - Expanded( - child: Container( - width: double.infinity, - decoration: BoxDecoration( - border: Border( - top: BorderSide( - width: 1.0, color: Theme.of(context).colorScheme.onSurfaceVariant), - ), - ), - child: Text(S.of(context).total + ':', - maxLines: 1, - style: textSmall(color: Theme.of(context).colorScheme.onSurfaceVariant)), - ), - ), - ], - )), - ], - ); - } -} - -class _EnterAmountWidget extends StatelessWidget { - const _EnterAmountWidget({ - required this.minValue, - required this.maxValue, - required this.fiatCurrency, - required this.amountFieldFocus, - required this.amountController, - required this.onAmountChanged, - }); - - final String minValue; - final String maxValue; - final String fiatCurrency; - final FocusNode amountFieldFocus; - final TextEditingController amountController; - final Function(String) onAmountChanged; - - @override - Widget build(BuildContext context) { - return Column( - children: [ - Container( - decoration: BoxDecoration( - border: Border( - bottom: BorderSide(width: 1.0, color: Theme.of(context).colorScheme.onSurfaceVariant), - ), - ), - child: BaseTextFormField( - controller: amountController, - keyboardType: TextInputType.numberWithOptions(signed: false, decimal: true), - hintText: '0.00', - maxLines: null, - prefixIcon: Padding( - padding: const EdgeInsets.only(top: 12), - child: Text( - '$fiatCurrency: ', - style: textMediumSemiBold(color: Theme.of(context).colorScheme.onSurface), - ), - ), - textStyle: textMediumSemiBold(color: Theme.of(context).colorScheme.onSurface), - placeholderTextStyle: - textMediumSemiBold(color: Theme.of(context).colorScheme.onSurfaceVariant), - inputFormatters: [ - FilteringTextInputFormatter.deny(RegExp('[\-|\ ]')), - FilteringTextInputFormatter.allow( - RegExp(r'^\d+(\.|\,)?\d{0,2}'), - ), - ], - ), - ), - SizedBox(height: 4), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text(S.of(context).min_amount(minValue) + ' $fiatCurrency', - style: textSmall(color: Theme.of(context).colorScheme.onSurfaceVariant)), - Text(S.of(context).max_amount(maxValue) + ' $fiatCurrency', - style: textSmall(color: Theme.of(context).colorScheme.onSurfaceVariant)), - ], - ), - ], - ); - } -} diff --git a/lib/src/screens/cake_pay/cards/cake_pay_cards_page.dart b/lib/src/screens/cake_pay/cards/cake_pay_cards_page.dart deleted file mode 100644 index cf75cfdb0..000000000 --- a/lib/src/screens/cake_pay/cards/cake_pay_cards_page.dart +++ /dev/null @@ -1,409 +0,0 @@ -import 'package:cake_wallet/cake_pay/cake_pay_states.dart'; -import 'package:cake_wallet/cake_pay/cake_pay_vendor.dart'; -import 'package:cake_wallet/entities/country.dart'; -import 'package:cake_wallet/generated/i18n.dart'; -import 'package:cake_wallet/routes.dart'; -import 'package:cake_wallet/src/screens/base_page.dart'; -import 'package:cake_wallet/src/screens/cake_pay/widgets/card_item.dart'; -import 'package:cake_wallet/src/screens/cake_pay/widgets/card_menu.dart'; -import 'package:cake_wallet/src/screens/dashboard/widgets/filter_widget.dart'; -import 'package:cake_wallet/src/widgets/base_text_form_field.dart'; -import 'package:cake_wallet/src/widgets/cake_scrollbar.dart'; -import 'package:cake_wallet/src/widgets/gradient_background.dart'; -import 'package:cake_wallet/src/widgets/picker.dart'; -import 'package:cake_wallet/typography.dart'; -import 'package:cake_wallet/utils/debounce.dart'; -import 'package:cake_wallet/utils/responsive_layout_util.dart'; -import 'package:cake_wallet/utils/show_pop_up.dart'; -import 'package:cake_wallet/view_model/cake_pay/cake_pay_cards_list_view_model.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_mobx/flutter_mobx.dart'; -import 'package:mobx/mobx.dart'; - -class CakePayCardsPage extends BasePage { - CakePayCardsPage(this._cardsListViewModel) : searchFocusNode = FocusNode() { - _searchController.addListener(() { - if (_searchController.text != _cardsListViewModel.searchString) { - _searchDebounce.run(() { - _cardsListViewModel.resetLoadingNextPageState(); - _cardsListViewModel.getVendors(text: _searchController.text); - }); - } - }); - } - - final FocusNode searchFocusNode; - final CakePayCardsListViewModel _cardsListViewModel; - - final _searchDebounce = Debounce(Duration(milliseconds: 500)); - final _searchController = TextEditingController(); - - @override - bool get gradientBackground => true; - - @override - Widget Function(BuildContext, Widget) get rootWrapper => - (BuildContext context, Widget scaffold) => GradientBackground(scaffold: scaffold); - - @override - bool get resizeToAvoidBottomInset => false; - - @override - Widget get endDrawer => CardMenu(); - - @override - Widget middle(BuildContext context) { - return Text( - 'Cake Pay', - style: textMediumSemiBold( - color: Theme.of(context).colorScheme.onSurface, - ), - ); - } - - @override - Widget trailing(BuildContext context) { - return _TrailingIcon( - asset: 'assets/images/profile.png', - iconColor: Theme.of(context).colorScheme.onSurface, - onPressed: () { - _cardsListViewModel.isCakePayUserAuthenticated().then( - (value) { - if (value) { - Navigator.pushNamed(context, Routes.cakePayAccountPage); - return; - } - Navigator.pushNamed(context, Routes.cakePayWelcomePage); - }, - ); - }, - ); - } - - @override - Widget body(BuildContext context) { - if (_cardsListViewModel.settingsStore.selectedCakePayCountry == null) { - WidgetsBinding.instance.addPostFrameCallback((_) { - reaction((_) => _cardsListViewModel.shouldShowCountryPicker, - (bool shouldShowCountryPicker) async { - if (shouldShowCountryPicker) { - _cardsListViewModel.storeInitialFilterStates(); - await showCountryPicker(context, _cardsListViewModel); - if (_cardsListViewModel.hasFiltersChanged) { - _cardsListViewModel.resetLoadingNextPageState(); - _cardsListViewModel.getVendors(); - } - - _cardsListViewModel.settingsStore.selectedCakePayCountry = - _cardsListViewModel.selectedCountry; - } - }); - }); - } - - final filterButton = Semantics( - label: S.of(context).filter_by, - child: GestureDetector( - onTap: () async { - _cardsListViewModel.storeInitialFilterStates(); - await showFilterWidget(context); - if (_cardsListViewModel.hasFiltersChanged) { - _cardsListViewModel.resetLoadingNextPageState(); - _cardsListViewModel.getVendors(); - } - }, - child: Container( - width: 32, - padding: EdgeInsets.only(top: 7, bottom: 7), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surface, - border: Border.all( - color: Colors.transparent, - ), - borderRadius: BorderRadius.circular(10), - ), - child: Image.asset( - 'assets/images/filter_icon.png', - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ), - ); - final _countryPicker = Semantics( - label: S.of(context).filter_by, - child: GestureDetector( - onTap: () async { - _cardsListViewModel.storeInitialFilterStates(); - await showCountryPicker(context, _cardsListViewModel); - if (_cardsListViewModel.hasFiltersChanged) { - _cardsListViewModel.resetLoadingNextPageState(); - _cardsListViewModel.getVendors(); - } - }, - child: Container( - padding: EdgeInsets.symmetric(horizontal: 8), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surface, - border: Border.all(color: Colors.transparent), - borderRadius: BorderRadius.circular(10), - ), - child: Container( - margin: EdgeInsets.symmetric(vertical: 4), - child: Row( - children: [ - Image.asset( - _cardsListViewModel.selectedCountry.iconPath, - width: 24, - height: 24, - errorBuilder: (context, error, stackTrace) => Container( - width: 24, - height: 24, - ), - ), - SizedBox(width: 6), - Text( - _cardsListViewModel.selectedCountry.countryCode, - style: Theme.of(context).textTheme.bodyMedium!.copyWith( - fontSize: 16, - fontWeight: FontWeight.w700, - ), - ), - ], - ), - ), - ), - ), - ); - - return Padding( - padding: const EdgeInsets.all(14.0), - child: Column( - children: [ - Container( - padding: EdgeInsets.only(left: 2, right: 22), - height: 32, - child: Row( - children: [ - Expanded( - child: _SearchWidget( - controller: _searchController, - focusNode: searchFocusNode, - ), - ), - SizedBox(width: 5), - filterButton, - SizedBox(width: 5), - _countryPicker - ], - ), - ), - SizedBox(height: 8), - Expanded(child: CakePayCardsPageBody(cardsListViewModel: _cardsListViewModel)) - ], - ), - ); - } - - Future showFilterWidget(BuildContext context) async { - return showPopUp( - context: context, - builder: (BuildContext context) { - return FilterWidget(filterItems: _cardsListViewModel.createFilterItems); - }, - ); - } -} - -Future showCountryPicker( - BuildContext context, CakePayCardsListViewModel cardsListViewModel) async { - await showPopUp( - context: context, - builder: (_) => Picker( - title: S.of(context).select_your_country, - items: cardsListViewModel.availableCountries, - images: cardsListViewModel.availableCountries - .map((e) => Image.asset( - e.iconPath, - errorBuilder: (context, error, stackTrace) => Container( - width: 58, - height: 58, - ), - )) - .toList(), - selectedAtIndex: - cardsListViewModel.availableCountries.indexOf(cardsListViewModel.selectedCountry), - onItemSelected: (Country country) => cardsListViewModel.setSelectedCountry(country), - isSeparated: false, - hintText: S.of(context).search, - matchingCriteria: (Country country, String searchText) => - country.fullName.toLowerCase().contains(searchText.toLowerCase()))); -} - -class CakePayCardsPageBody extends StatefulWidget { - const CakePayCardsPageBody({ - Key? key, - required this.cardsListViewModel, - }) : super(key: key); - - final CakePayCardsListViewModel cardsListViewModel; - - @override - _CakePayCardsPageBodyState createState() => _CakePayCardsPageBodyState(); -} - -class _CakePayCardsPageBodyState extends State { - double get backgroundHeight => MediaQuery.of(context).size.height * 0.75; - double thumbHeight = 72; - - bool get isAlwaysShowScrollThumb => merchantsList.isEmpty ? false : merchantsList.length > 3; - - List get merchantsList => widget.cardsListViewModel.cakePayVendors; - - final _scrollController = ScrollController(); - - @override - void initState() { - _scrollController.addListener(() { - final scrollOffsetFromTop = _scrollController.hasClients - ? (_scrollController.offset / - _scrollController.position.maxScrollExtent * - (backgroundHeight - thumbHeight)) - : 0.0; - widget.cardsListViewModel.setScrollOffsetFromTop(scrollOffsetFromTop); - - double threshold = 200.0; - bool isNearBottom = - _scrollController.offset >= _scrollController.position.maxScrollExtent - threshold; - if (isNearBottom && !_scrollController.position.outOfRange) { - widget.cardsListViewModel.fetchNextPage(); - } - }); - super.initState(); - } - - @override - Widget build(BuildContext context) { - return Observer(builder: (_) { - final vendorsState = widget.cardsListViewModel.vendorsState; - if (vendorsState is CakePayVendorLoadedState) { - bool isLoadingMore = widget.cardsListViewModel.isLoadingNextPage; - final vendors = widget.cardsListViewModel.cakePayVendors; - - if (vendors.isEmpty) { - return Center(child: Text(S.of(context).no_cards_found)); - } - return Stack(children: [ - GridView.builder( - controller: _scrollController, - gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: responsiveLayoutUtil.shouldRenderTabletUI ? 2 : 1, - childAspectRatio: 5, - crossAxisSpacing: responsiveLayoutUtil.shouldRenderTabletUI ? 10 : 5, - mainAxisSpacing: responsiveLayoutUtil.shouldRenderTabletUI ? 10 : 5, - ), - padding: EdgeInsets.only(left: 2, right: 22), - itemCount: vendors.length + (isLoadingMore ? 1 : 0), - itemBuilder: (_, index) { - if (index >= vendors.length) { - return _VendorLoadedIndicator(); - } - final vendor = vendors[index]; - return CardItem( - logoUrl: vendor.card?.cardImageUrl, - onTap: () { - Navigator.of(context).pushNamed(Routes.cakePayBuyCardPage, arguments: [vendor]); - }, - title: vendor.name, - subTitle: vendor.card?.description ?? '', - backgroundColor: Theme.of(context).colorScheme.surface, - titleColor: Theme.of(context).colorScheme.onSurface, - subtitleColor: Theme.of(context).colorScheme.onSecondary, - discount: 0.0, - ); - }, - ), - isAlwaysShowScrollThumb - ? CakeScrollbar( - backgroundHeight: backgroundHeight, - thumbHeight: thumbHeight, - rightOffset: 1, - width: 3, - backgroundColor: Theme.of(context).colorScheme.onSurfaceVariant.withOpacity(0.05), - thumbColor: Theme.of(context).colorScheme.onSurfaceVariant.withOpacity(0.5), - fromTop: widget.cardsListViewModel.scrollOffsetFromTop, - ) - : Offstage() - ]); - } - return _VendorLoadedIndicator(); - }); - } -} - -class _VendorLoadedIndicator extends StatelessWidget { - @override - Widget build(BuildContext context) { - return Center( - child: CircularProgressIndicator( - backgroundColor: Theme.of(context).colorScheme.onSurface, - valueColor: AlwaysStoppedAnimation(Theme.of(context).colorScheme.primary), - ), - ); - } -} - -class _SearchWidget extends StatelessWidget { - const _SearchWidget({ - Key? key, - required this.controller, - required this.focusNode, - }) : super(key: key); - final TextEditingController controller; - final FocusNode focusNode; - - @override - Widget build(BuildContext context) { - return BaseTextFormField( - focusNode: focusNode, - textStyle: Theme.of(context).textTheme.bodyMedium, - controller: controller, - contentPadding: EdgeInsets.only(top: 8, left: 8), - hintText: S.of(context).search, - placeholderTextStyle: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Theme.of(context).colorScheme.onSurface, - ), - alignLabelWithHint: true, - floatingLabelBehavior: FloatingLabelBehavior.never, - suffixIcon: ExcludeSemantics( - child: Icon( - Icons.search, - color: Theme.of(context).colorScheme.primary, - ), - ), - ); - } -} - -class _TrailingIcon extends StatelessWidget { - const _TrailingIcon({required this.asset, this.onPressed, required this.iconColor}); - - final String asset; - final VoidCallback? onPressed; - final Color iconColor; - - @override - Widget build(BuildContext context) { - return Semantics( - label: S.of(context).profile, - child: Material( - color: Colors.transparent, - child: IconButton( - padding: EdgeInsets.zero, - constraints: BoxConstraints(), - highlightColor: Colors.transparent, - onPressed: onPressed, - icon: ImageIcon(AssetImage(asset), size: 25, color: iconColor), - ), - )); - } -} diff --git a/lib/src/screens/cake_pay/cards/cake_pay_confirm_purchase_card_page.dart b/lib/src/screens/cake_pay/cards/cake_pay_confirm_purchase_card_page.dart deleted file mode 100644 index bf37dfa18..000000000 --- a/lib/src/screens/cake_pay/cards/cake_pay_confirm_purchase_card_page.dart +++ /dev/null @@ -1,703 +0,0 @@ -import 'package:cake_wallet/cake_pay/cake_pay_card.dart'; -import 'package:cake_wallet/core/execution_state.dart'; -import 'package:cake_wallet/generated/i18n.dart'; -import 'package:cake_wallet/routes.dart'; -import 'package:cake_wallet/src/screens/base_page.dart'; -import 'package:cake_wallet/src/screens/cake_pay/widgets/cake_pay_alert_modal.dart'; -import 'package:cake_wallet/src/screens/cake_pay/widgets/image_placeholder.dart'; -import 'package:cake_wallet/src/screens/cake_pay/widgets/link_extractor.dart'; -import 'package:cake_wallet/src/screens/cake_pay/widgets/text_icon_button.dart'; -import 'package:cake_wallet/src/widgets/alert_with_one_action.dart'; -import 'package:cake_wallet/src/widgets/alert_with_two_actions.dart'; -import 'package:cake_wallet/src/widgets/base_alert_dialog.dart'; -import 'package:cake_wallet/src/widgets/bottom_sheet/confirm_sending_bottom_sheet_widget.dart'; -import 'package:cake_wallet/src/widgets/bottom_sheet/info_bottom_sheet_widget.dart'; -import 'package:cake_wallet/src/widgets/primary_button.dart'; -import 'package:cake_wallet/src/widgets/scollable_with_bottom_section.dart'; -import 'package:cake_wallet/src/widgets/standard_checkbox.dart'; -import 'package:cake_wallet/utils/show_pop_up.dart'; -import 'package:cake_wallet/view_model/cake_pay/cake_pay_purchase_view_model.dart'; -import 'package:cake_wallet/view_model/send/send_view_model_state.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_mobx/flutter_mobx.dart'; -import 'package:mobx/mobx.dart'; -import 'package:url_launcher/url_launcher.dart'; - -class CakePayBuyCardDetailPage extends BasePage { - CakePayBuyCardDetailPage(this.cakePayPurchaseViewModel); - - final CakePayPurchaseViewModel cakePayPurchaseViewModel; - - @override - String get title => cakePayPurchaseViewModel.card.name; - - @override - Widget? middle(BuildContext context) { - return Text( - title, - textAlign: TextAlign.center, - maxLines: 2, - style: Theme.of(context).textTheme.titleLarge?.copyWith( - color: Theme.of(context).colorScheme.onSurface, - fontWeight: FontWeight.bold, - ), - ); - } - - @override - Widget? trailing(BuildContext context) => null; - - bool _effectsInstalled = false; - - @override - Widget body(BuildContext context) { - _setEffects(context); - - final card = cakePayPurchaseViewModel.card; - - return ScrollableWithBottomSection( - contentPadding: EdgeInsets.zero, - content: Observer(builder: (_) { - return Column( - children: [ - SizedBox(height: 36), - ClipRRect( - borderRadius: - BorderRadius.horizontal(left: Radius.circular(20), right: Radius.circular(20)), - child: Container( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerLowest, - borderRadius: BorderRadius.circular(20), - border: Border.all( - color: Theme.of(context).colorScheme.outline.withOpacity(0.20), - ), - ), - child: Row( - children: [ - Expanded( - child: Container( - child: ClipRRect( - borderRadius: BorderRadius.horizontal( - left: Radius.circular(20), right: Radius.circular(20)), - child: Image.network( - card.cardImageUrl ?? '', - fit: BoxFit.cover, - loadingBuilder: (BuildContext context, Widget child, - ImageChunkEvent? loadingProgress) { - if (loadingProgress == null) return child; - return Center(child: CircularProgressIndicator()); - }, - errorBuilder: (context, error, stackTrace) => - CakePayCardImagePlaceholder(), - ), - )), - ), - Expanded( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 8.0), - child: Column(children: [ - Row( - children: [ - Text( - S.of(context).value + ':', - style: Theme.of(context).textTheme.titleLarge?.copyWith( - color: Theme.of(context).colorScheme.onSurface, - ), - ), - SizedBox(width: 8), - Text( - '${cakePayPurchaseViewModel.amount.toStringAsFixed(2)} ${cakePayPurchaseViewModel.fiatCurrency}', - style: Theme.of(context).textTheme.titleLarge?.copyWith( - color: Theme.of(context).colorScheme.onSurface, - ), - ), - ], - ), - SizedBox(height: 16), - Row( - children: [ - Text( - S.of(context).quantity + ':', - style: Theme.of(context).textTheme.titleLarge?.copyWith( - color: Theme.of(context).colorScheme.onSurface, - ), - ), - SizedBox(width: 8), - Text( - '${cakePayPurchaseViewModel.quantity}', - style: Theme.of(context).textTheme.titleLarge?.copyWith( - color: Theme.of(context).colorScheme.onSurface, - ), - ), - ], - ), - SizedBox(height: 16), - Row( - children: [ - Text( - S.of(context).total + ':', - style: Theme.of(context).textTheme.titleLarge?.copyWith( - color: Theme.of(context).colorScheme.onSurface, - ), - ), - SizedBox(width: 8), - Text( - '${cakePayPurchaseViewModel.totalAmount.toStringAsFixed(2)} ${cakePayPurchaseViewModel.fiatCurrency}', - style: Theme.of(context).textTheme.titleLarge?.copyWith( - color: Theme.of(context).colorScheme.onSurface, - ), - ), - ], - ), - ]), - ), - ) - ], - ), - ), - ), - SizedBox(height: 20), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 24.0), - child: TextIconButton( - label: S.of(context).how_to_use_card, - onTap: () => _showHowToUseCard(context, card), - ), - ), - SizedBox(height: 20), - if (card.expiryAndValidity != null && card.expiryAndValidity!.isNotEmpty) - Padding( - padding: const EdgeInsets.symmetric(horizontal: 24.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(S.of(context).expiry_and_validity + ':', - style: Theme.of(context).textTheme.titleLarge?.copyWith( - color: Theme.of(context).colorScheme.onSurface, - fontWeight: FontWeight.w600, - )), - SizedBox(height: 10), - Container( - width: double.infinity, - decoration: BoxDecoration( - border: Border( - bottom: BorderSide( - color: Theme.of(context).colorScheme.outline.withOpacity(0.20), - width: 1, - ), - ), - ), - child: Padding( - padding: const EdgeInsets.only(bottom: 8.0), - child: Text( - card.expiryAndValidity ?? '', - style: Theme.of(context).textTheme.bodyLarge?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ), - ), - ], - ), - ), - ], - ); - }), - bottomSection: Column( - children: [ - Padding( - padding: EdgeInsets.only(bottom: 12), - child: Observer(builder: (_) { - return LoadingPrimaryButton( - isDisabled: cakePayPurchaseViewModel.isPurchasing, - isLoading: cakePayPurchaseViewModel.isPurchasing || - cakePayPurchaseViewModel.sendViewModel.state is IsExecutingState, - onPressed: () => confirmPurchaseFirst(context), - text: S.of(context).purchase_gift_card, - color: Theme.of(context).colorScheme.primary, - textColor: Theme.of(context).colorScheme.onPrimary, - ); - }), - ), - SizedBox(height: 8), - InkWell( - onTap: () => _showTermsAndCondition(context, card.termsAndConditions), - child: Text( - S.of(context).settings_terms_and_conditions, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.primary, - fontWeight: FontWeight.w600, - ), - ), - ), - SizedBox(height: 16) - ], - ), - ); - } - - void _showTermsAndCondition(BuildContext context, String? termsAndConditions) { - showPopUp( - context: context, - builder: (BuildContext context) { - return CakePayAlertModal( - title: S.of(context).settings_terms_and_conditions, - content: Align( - alignment: Alignment.bottomLeft, - child: ClickableLinksText( - text: termsAndConditions ?? '', - textStyle: Theme.of(context).textTheme.bodyLarge?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - fontSize: 18, - ) ?? - Theme.of(context).textTheme.bodyMedium!.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - fontSize: 18, - ), - ), - ), - actionTitle: S.of(context).agree, - showCloseButton: false, - heightFactor: 0.6, - ); - }); - } - - Future _showconfirmPurchaseFirstAlert(BuildContext context) async { - if (!cakePayPurchaseViewModel.confirmsNoVpn || - !cakePayPurchaseViewModel.confirmsVoidedRefund || - !cakePayPurchaseViewModel.confirmsTermsAgreed) { - await showPopUp( - context: context, - builder: (BuildContext context) => ThreeCheckboxAlert( - alertTitle: S.of(context).cakepay_confirm_purchase, - leftButtonText: S.of(context).cancel, - rightButtonText: S.of(context).confirm, - actionLeftButton: () { - cakePayPurchaseViewModel.isPurchasing = false; - Navigator.of(context).pop(); - }, - actionRightButton: (confirmsNoVpn, confirmsVoidedRefund, confirmsTermsAgreed) { - cakePayPurchaseViewModel.confirmsNoVpn = confirmsNoVpn; - cakePayPurchaseViewModel.confirmsVoidedRefund = confirmsVoidedRefund; - cakePayPurchaseViewModel.confirmsTermsAgreed = confirmsTermsAgreed; - - Navigator.of(context).pop(); - }, - ), - ); - } - - if (cakePayPurchaseViewModel.confirmsNoVpn && - cakePayPurchaseViewModel.confirmsVoidedRefund && - cakePayPurchaseViewModel.confirmsTermsAgreed) { - await purchaseCard(context); - } - } - - Future confirmPurchaseFirst(BuildContext context) async { - bool isLogged = await cakePayPurchaseViewModel.cakePayService.isLogged(); - if (!isLogged) { - Navigator.of(context).pushNamed(Routes.cakePayWelcomePage); - } else { - cakePayPurchaseViewModel.isPurchasing = true; - await _showconfirmPurchaseFirstAlert(context); - } - } - - Future purchaseCard(BuildContext context) async { - bool isLogged = await cakePayPurchaseViewModel.cakePayService.isLogged(); - if (!isLogged) { - Navigator.of(context).pushNamed(Routes.cakePayWelcomePage); - } else { - try { - await cakePayPurchaseViewModel.createOrder(); - } catch (_) { - await cakePayPurchaseViewModel.cakePayService.logout(); - } - } - cakePayPurchaseViewModel.isPurchasing = false; - } - - void _showHowToUseCard( - BuildContext context, - CakePayCard card, - ) { - showPopUp( - context: context, - builder: (BuildContext context) { - return CakePayAlertModal( - title: S.of(context).how_to_use_card, - content: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Padding( - padding: EdgeInsets.all(10), - child: Text( - card.name, - style: Theme.of(context).textTheme.titleLarge?.copyWith( - color: Theme.of(context).colorScheme.onSurface, - fontWeight: FontWeight.w600, - ), - )), - ClickableLinksText( - text: card.howToUse ?? '', - textStyle: Theme.of(context).textTheme.bodyLarge?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ) ?? - Theme.of(context).textTheme.bodyMedium!.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - linkStyle: Theme.of(context).textTheme.bodyLarge?.copyWith( - color: Theme.of(context).colorScheme.onSurface, - fontStyle: FontStyle.italic, - ) ?? - Theme.of(context).textTheme.bodyMedium!.copyWith( - color: Theme.of(context).colorScheme.onSurface, - fontStyle: FontStyle.italic, - ), - ), - ]), - actionTitle: S.current.got_it, - ); - }); - } - - Future _showConfirmSendingAlert(BuildContext context) async { - if (cakePayPurchaseViewModel.order == null) { - return; - } - ReactionDisposer? disposer; - - disposer = reaction((_) => cakePayPurchaseViewModel.isOrderExpired, (bool isExpired) { - if (isExpired) { - if (Navigator.of(context).canPop()) { - Navigator.of(context).pop(); - } - if (disposer != null) { - disposer(); - } - } - }); - - final order = cakePayPurchaseViewModel.order; - - showModalBottomSheet( - context: context, - isDismissible: false, - isScrollControlled: true, - builder: (BuildContext popupContext) { - return ConfirmSendingBottomSheet( - key: ValueKey('send_page_confirm_sending_dialog_key'), - currentTheme: currentTheme, - walletType: cakePayPurchaseViewModel.sendViewModel.walletType, - paymentId: S.of(popupContext).payment_id, - paymentIdValue: order?.orderId, - expirationTime: cakePayPurchaseViewModel.formattedRemainingTime, - titleText: S.of(popupContext).confirm_transaction, - titleIconPath: cakePayPurchaseViewModel.sendViewModel.selectedCryptoCurrency.iconPath, - currency: cakePayPurchaseViewModel.sendViewModel.selectedCryptoCurrency, - amount: S.of(popupContext).send_amount, - amountValue: cakePayPurchaseViewModel.sendViewModel.pendingTransaction!.amountFormatted, - fiatAmountValue: - cakePayPurchaseViewModel.sendViewModel.pendingTransactionFiatAmountFormatted, - fee: S.of(popupContext).send_fee, - feeValue: cakePayPurchaseViewModel.sendViewModel.pendingTransaction!.feeFormatted, - feeFiatAmount: - cakePayPurchaseViewModel.sendViewModel.pendingTransactionFeeFiatAmountFormatted, - outputs: cakePayPurchaseViewModel.sendViewModel.outputs, - onSlideComplete: () async { - Navigator.of(popupContext).pop(); - cakePayPurchaseViewModel.sendViewModel.commitTransaction(context); - }, - ); - }, - ); - } - - BuildContext? loadingBottomSheetContext; - - void _setEffects(BuildContext context) { - if (_effectsInstalled) { - return; - } - - reaction((_) => cakePayPurchaseViewModel.sendViewModel.state, (ExecutionState state) { - if (state is FailureState) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (context.mounted) showStateAlert(context, S.of(context).error, state.error); - }); - } - - if (state is! IsExecutingState && - loadingBottomSheetContext != null && - loadingBottomSheetContext!.mounted) { - Navigator.of(loadingBottomSheetContext!).pop(); - } - - if (state is IsExecutingState) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (context.mounted) { - showModalBottomSheet( - context: context, - isDismissible: false, - builder: (BuildContext context) { - loadingBottomSheetContext = context; - return LoadingBottomSheet( - titleText: S.of(context).generating_transaction, - ); - }, - ); - } - }); - } - - if (state is ExecutedSuccessfullyState) { - WidgetsBinding.instance.addPostFrameCallback((_) async { - await _showConfirmSendingAlert(context); - }); - } - - if (state is TransactionCommitted) { - WidgetsBinding.instance.addPostFrameCallback((_) { - cakePayPurchaseViewModel.sendViewModel.clearOutputs(); - if (context.mounted) showSentAlert(context); - }); - } - }); - - _effectsInstalled = true; - } - - void showStateAlert(BuildContext context, String title, String content) { - if (context.mounted) { - showPopUp( - context: context, - builder: (BuildContext context) { - return AlertWithOneAction( - alertTitle: title, - alertContent: content, - buttonText: S.of(context).ok, - buttonAction: () => Navigator.of(context).pop()); - }); - } - } - - Future showSentAlert(BuildContext context) async { - if (!context.mounted) { - return; - } - final order = cakePayPurchaseViewModel.order!.orderId; - final isCopy = await showPopUp( - context: context, - builder: (BuildContext context) { - return AlertWithTwoActions( - alertTitle: S.of(context).transaction_sent, - alertContent: S.of(context).cake_pay_save_order + '\n${order}', - leftButtonText: S.of(context).ignor, - rightButtonText: S.of(context).copy, - actionLeftButton: () => Navigator.of(context).pop(false), - actionRightButton: () => Navigator.of(context).pop(true)); - }) ?? - false; - - if (isCopy) { - await Clipboard.setData(ClipboardData(text: order)); - } - } - - void _handleDispose(ReactionDisposer? disposer) { - cakePayPurchaseViewModel.dispose(); - if (disposer != null) { - disposer(); - } - } -} - -class ThreeCheckboxAlert extends BaseAlertDialog { - ThreeCheckboxAlert({ - required this.alertTitle, - required this.leftButtonText, - required this.rightButtonText, - required this.actionLeftButton, - required this.actionRightButton, - this.alertBarrierDismissible = true, - Key? key, - }); - - final String alertTitle; - final String leftButtonText; - final String rightButtonText; - final VoidCallback actionLeftButton; - final Function(bool, bool, bool) actionRightButton; - final bool alertBarrierDismissible; - - bool checkbox1 = false; - void toggleCheckbox1() => checkbox1 = !checkbox1; - bool checkbox2 = false; - void toggleCheckbox2() => checkbox2 = !checkbox2; - bool checkbox3 = false; - void toggleCheckbox3() => checkbox3 = !checkbox3; - - bool showValidationMessage = true; - - @override - String get titleText => alertTitle; - @override - bool get isDividerExists => true; - - @override - String get leftActionButtonText => leftButtonText; - @override - String get rightActionButtonText => rightButtonText; - @override - VoidCallback get actionLeft => actionLeftButton; - @override - VoidCallback get actionRight => () { - actionRightButton(checkbox1, checkbox2, checkbox3); - }; - - @override - bool get barrierDismissible => alertBarrierDismissible; - - @override - Widget content(BuildContext context) { - return ThreeCheckboxAlertContent( - checkbox1: checkbox1, - toggleCheckbox1: toggleCheckbox1, - checkbox2: checkbox2, - toggleCheckbox2: toggleCheckbox2, - checkbox3: checkbox3, - toggleCheckbox3: toggleCheckbox3, - ); - } -} - -class ThreeCheckboxAlertContent extends StatefulWidget { - ThreeCheckboxAlertContent({ - required this.checkbox1, - required this.toggleCheckbox1, - required this.checkbox2, - required this.toggleCheckbox2, - required this.checkbox3, - required this.toggleCheckbox3, - Key? key, - }) : super(key: key); - - bool checkbox1; - void Function() toggleCheckbox1; - bool checkbox2; - void Function() toggleCheckbox2; - bool checkbox3; - void Function() toggleCheckbox3; - - @override - _ThreeCheckboxAlertContentState createState() => _ThreeCheckboxAlertContentState( - checkbox1: checkbox1, - toggleCheckbox1: toggleCheckbox1, - checkbox2: checkbox2, - toggleCheckbox2: toggleCheckbox2, - checkbox3: checkbox3, - toggleCheckbox3: toggleCheckbox3, - ); - - static _ThreeCheckboxAlertContentState? of(BuildContext context) { - return context.findAncestorStateOfType<_ThreeCheckboxAlertContentState>(); - } -} - -class _ThreeCheckboxAlertContentState extends State { - _ThreeCheckboxAlertContentState({ - required this.checkbox1, - required this.toggleCheckbox1, - required this.checkbox2, - required this.toggleCheckbox2, - required this.checkbox3, - required this.toggleCheckbox3, - }); - - bool checkbox1; - void Function() toggleCheckbox1; - bool checkbox2; - void Function() toggleCheckbox2; - bool checkbox3; - void Function() toggleCheckbox3; - - bool showValidationMessage = true; - - bool get areAllCheckboxesChecked => checkbox1 && checkbox2 && checkbox3; - - @override - Widget build(BuildContext context) { - return Form( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - StandardCheckbox( - value: checkbox1, - caption: S.of(context).cakepay_confirm_no_vpn, - onChanged: (bool? value) { - setState(() { - checkbox1 = value ?? false; - toggleCheckbox1(); - showValidationMessage = !areAllCheckboxesChecked; - }); - }, - ), - StandardCheckbox( - value: checkbox2, - caption: S.of(context).cakepay_confirm_voided_refund, - onChanged: (bool? value) { - setState(() { - checkbox2 = value ?? false; - toggleCheckbox2(); - showValidationMessage = !areAllCheckboxesChecked; - }); - }, - ), - StandardCheckbox( - value: checkbox3, - caption: S.of(context).cakepay_confirm_terms_agreed, - onChanged: (bool? value) { - setState(() { - checkbox3 = value ?? false; - toggleCheckbox3(); - showValidationMessage = !areAllCheckboxesChecked; - }); - }, - ), - GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: () => launchUrl( - Uri.parse("https://www.cakepay.com/terms/"), - mode: LaunchMode.externalApplication, - ), - child: Padding( - padding: const EdgeInsets.only(top: 8.0), - child: Text( - S.of(context).settings_terms_and_conditions, - style: Theme.of(context).textTheme.bodyMedium!.copyWith( - fontSize: 16, - color: Theme.of(context).colorScheme.primary, - decoration: TextDecoration.none, - height: 1, - ), - softWrap: true, - ), - ), - ), - if (showValidationMessage) - Padding( - padding: const EdgeInsets.only(top: 8.0), - child: Text( - 'Please confirm all checkboxes', - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Theme.of(context).colorScheme.errorContainer, - decoration: TextDecoration.none, - ), - ), - ), - ], - ), - ); - } -} diff --git a/lib/src/screens/cake_pay/widgets/cake_pay_alert_modal.dart b/lib/src/screens/cake_pay/widgets/cake_pay_alert_modal.dart deleted file mode 100644 index bd6cb7cdb..000000000 --- a/lib/src/screens/cake_pay/widgets/cake_pay_alert_modal.dart +++ /dev/null @@ -1,86 +0,0 @@ -import 'package:cake_wallet/src/widgets/alert_background.dart'; -import 'package:cake_wallet/src/widgets/primary_button.dart'; -import 'package:flutter/material.dart'; - -class CakePayAlertModal extends StatelessWidget { - const CakePayAlertModal({ - Key? key, - required this.title, - required this.content, - required this.actionTitle, - this.heightFactor = 0.4, - this.showCloseButton = true, - }) : super(key: key); - - final String title; - final Widget content; - final String actionTitle; - final bool showCloseButton; - final double heightFactor; - - @override - Widget build(BuildContext context) { - return AlertBackground( - child: Material( - color: Colors.transparent, - child: Column( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - Spacer(), - Container( - padding: EdgeInsets.only(top: 24, left: 24, right: 24), - margin: EdgeInsets.all(24), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surface, - borderRadius: BorderRadius.circular(30), - ), - child: Column( - children: [ - if (title.isNotEmpty) - Text( - title, - style: Theme.of(context).textTheme.titleLarge?.copyWith( - color: Theme.of(context).colorScheme.onSurface, - fontWeight: FontWeight.bold, - ), - ), - Container( - constraints: BoxConstraints(maxHeight: MediaQuery.of(context).size.height * heightFactor), - child: ListView( - children: [ - content, - SizedBox(height: 35), - ], - ), - ), - PrimaryButton( - onPressed: () => Navigator.pop(context), - text: actionTitle, - color: Theme.of(context).colorScheme.surfaceContainer, - textColor: Theme.of(context).colorScheme.primary, - ), - SizedBox(height: 21), - ], - ), - ), - Spacer(), - if(showCloseButton) - InkWell( - onTap: () => Navigator.pop(context), - child: Container( - margin: EdgeInsets.only(bottom: 40), - child: CircleAvatar( - child: Icon( - Icons.close, - color: Theme.of(context).colorScheme.onSurface, - ), - backgroundColor: Theme.of(context).colorScheme.surfaceContainer, - ), - ), - ) - ], - ), - ), - ); - } -} \ No newline at end of file diff --git a/lib/src/screens/cake_pay/widgets/card_menu.dart b/lib/src/screens/cake_pay/widgets/card_menu.dart deleted file mode 100644 index 9212c0448..000000000 --- a/lib/src/screens/cake_pay/widgets/card_menu.dart +++ /dev/null @@ -1,11 +0,0 @@ -import 'package:flutter/material.dart'; - -class CardMenu extends StatelessWidget { - - @override - Widget build(BuildContext context) { - return Container( - - ); - } -} \ No newline at end of file diff --git a/lib/src/screens/dashboard/widgets/filter_widget.dart b/lib/src/screens/dashboard/widgets/filter_widget.dart index fa8daea2d..73e0f9bfb 100644 --- a/lib/src/screens/dashboard/widgets/filter_widget.dart +++ b/lib/src/screens/dashboard/widgets/filter_widget.dart @@ -78,11 +78,14 @@ class _FilterWidgetState extends State { children: [ Padding( padding: const EdgeInsets.all(24.0), - child: Text( - S.of(context).filter_by, - style: Theme.of(context).textTheme.titleMedium?.copyWith( - color: Theme.of(context).colorScheme.onSurface, - fontWeight: FontWeight.bold, + child: Align( + alignment: Alignment.center, + child: Text( + S.of(context).filter_by, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurface, + fontWeight: FontWeight.bold, + ), ), ), ), @@ -105,7 +108,7 @@ class _FilterWidgetState extends State { final Widget sectionListView = ListView.builder( controller: isSectionScrollable ? _scrollController : null, - padding: const EdgeInsets.symmetric(horizontal: 28.0), + padding: const EdgeInsets.symmetric(horizontal: 28.0, vertical: 8.0), shrinkWrap: isSectionScrollable ? false : true, physics: isSectionScrollable ? const BouncingScrollPhysics() diff --git a/lib/src/screens/dashboard/widgets/present_receive_option_picker.dart b/lib/src/screens/dashboard/widgets/present_receive_option_picker.dart index f69586a65..082b26e0b 100644 --- a/lib/src/screens/dashboard/widgets/present_receive_option_picker.dart +++ b/lib/src/screens/dashboard/widgets/present_receive_option_picker.dart @@ -1,5 +1,5 @@ import 'package:cake_wallet/src/widgets/alert_close_button.dart'; -import 'package:cake_wallet/src/screens/cake_pay/widgets/rounded_checkbox.dart'; +import 'package:cake_wallet/src/widgets/rounded_checkbox.dart'; import 'package:cake_wallet/src/widgets/alert_background.dart'; import 'package:cake_wallet/typography.dart'; import 'package:cake_wallet/utils/show_pop_up.dart'; diff --git a/lib/src/screens/exchange_trade/exchange_trade_page.dart b/lib/src/screens/exchange_trade/exchange_trade_page.dart index cd118f945..961d2ad8c 100644 --- a/lib/src/screens/exchange_trade/exchange_trade_page.dart +++ b/lib/src/screens/exchange_trade/exchange_trade_page.dart @@ -3,6 +3,7 @@ import 'package:cake_wallet/routes.dart'; import 'package:cake_wallet/src/screens/exchange/widgets/desktop_exchange_cards_section.dart'; import 'package:cake_wallet/src/screens/exchange/widgets/mobile_exchange_cards_section.dart'; import 'package:cake_wallet/src/screens/exchange_trade/widgets/exchange_trade_card_item_widget.dart'; +import 'package:cake_wallet/src/widgets/bottom_sheet/base_bottom_sheet_widget.dart'; import 'package:cake_wallet/src/widgets/bottom_sheet/confirm_sending_bottom_sheet_widget.dart'; import 'package:cake_wallet/src/widgets/bottom_sheet/info_bottom_sheet_widget.dart'; import 'package:cake_wallet/themes/core/material_base_theme.dart'; @@ -274,6 +275,7 @@ class ExchangeTradeState extends State { return ConfirmSendingBottomSheet( key: ValueKey('exchange_trade_page_confirm_sending_bottom_sheet_key'), currentTheme: widget.currentTheme, + footerType: FooterType.slideActionButton, walletType: widget.exchangeTradeViewModel.sendViewModel.walletType, titleText: S.of(bottomSheetContext).confirm_transaction, titleIconPath: @@ -293,7 +295,7 @@ class ExchangeTradeState extends State { feeFiatAmount: widget.exchangeTradeViewModel.sendViewModel .pendingTransactionFeeFiatAmountFormatted, outputs: widget.exchangeTradeViewModel.sendViewModel.outputs, - onSlideComplete: () async { + onSlideActionComplete: () async { if (bottomSheetContext.mounted) { Navigator.of(bottomSheetContext).pop(); } @@ -317,11 +319,12 @@ class ExchangeTradeState extends State { builder: (BuildContext bottomSheetContext) { return InfoBottomSheet( currentTheme: widget.currentTheme, + footerType: FooterType.singleActionButton, titleText: S.of(bottomSheetContext).transaction_sent, contentImage: 'assets/images/birthday_cake.png', - actionButtonText: S.of(bottomSheetContext).close, - actionButtonKey: ValueKey('send_page_sent_dialog_ok_button_key'), - actionButton: () { + singleActionButtonText: S.of(bottomSheetContext).close, + singleActionButtonKey: ValueKey('send_page_sent_dialog_ok_button_key'), + onSingleActionButtonPressed: () { Navigator.of(bottomSheetContext).pop(); if (mounted) { Navigator.of(context).pushNamedAndRemoveUntil( diff --git a/lib/src/screens/integrations/deuro/savings_page.dart b/lib/src/screens/integrations/deuro/savings_page.dart index 565b4dd9c..38054c09d 100644 --- a/lib/src/screens/integrations/deuro/savings_page.dart +++ b/lib/src/screens/integrations/deuro/savings_page.dart @@ -6,6 +6,7 @@ import 'package:cake_wallet/src/screens/integrations/deuro/widgets/interest_card import 'package:cake_wallet/src/screens/integrations/deuro/widgets/savings_card_widget.dart'; import 'package:cake_wallet/src/screens/integrations/deuro/widgets/savings_edit_sheet.dart'; import 'package:cake_wallet/src/widgets/alert_with_one_action.dart'; +import 'package:cake_wallet/src/widgets/bottom_sheet/base_bottom_sheet_widget.dart'; import 'package:cake_wallet/src/widgets/bottom_sheet/confirm_sending_bottom_sheet_widget.dart'; import 'package:cake_wallet/src/widgets/bottom_sheet/info_bottom_sheet_widget.dart'; import 'package:cake_wallet/src/widgets/gradient_background.dart'; @@ -166,6 +167,7 @@ class DEuroSavingsPage extends BasePage { isScrollControlled: true, builder: (BuildContext bottomSheetContext) => ConfirmSendingBottomSheet( key: ValueKey('savings_page_confirm_sending_dialog_key'), + footerType: FooterType.slideActionButton, titleText: title, currentTheme: currentTheme, walletType: WalletType.ethereum, @@ -182,7 +184,7 @@ class DEuroSavingsPage extends BasePage { feeValue: tx.feeFormatted, feeFiatAmount: _dEuroViewModel.pendingTransactionFeeFiatAmountFormatted, outputs: [], - onSlideComplete: () async { + onSlideActionComplete: () async { Navigator.of(bottomSheetContext).pop(true); dEuroViewModel.commitTransaction(); }, @@ -201,6 +203,7 @@ class DEuroSavingsPage extends BasePage { isScrollControlled: true, builder: (BuildContext bottomSheetContext) => ConfirmSendingBottomSheet( key: ValueKey('savings_page_confirm_approval_dialog_key'), + footerType: FooterType.slideActionButton, titleText: S.of(bottomSheetContext).approve_tokens, currentTheme: currentTheme, walletType: WalletType.ethereum, @@ -213,7 +216,7 @@ class DEuroSavingsPage extends BasePage { feeValue: tx.feeFormatted, feeFiatAmount: "", outputs: [], - onSlideComplete: () { + onSlideActionComplete: () { Navigator.of(bottomSheetContext).pop(true); dEuroViewModel.commitApprovalTransaction(); }, @@ -234,13 +237,14 @@ class DEuroSavingsPage extends BasePage { context: context, isDismissible: false, builder: (BuildContext bottomSheetContext) => InfoBottomSheet( + footerType: FooterType.singleActionButton, currentTheme: currentTheme, titleText: S.of(bottomSheetContext).transaction_sent, contentImage: 'assets/images/birthday_cake.png', content: S.of(bottomSheetContext).deuro_tx_commited_content, - actionButtonText: S.of(bottomSheetContext).close, - actionButtonKey: ValueKey('savings_page_sent_dialog_ok_button_key'), - actionButton: () => Navigator.of(bottomSheetContext).pop(), + singleActionButtonText: S.of(bottomSheetContext).close, + singleActionButtonKey: ValueKey('send_page_sent_dialog_ok_button_key'), + onSingleActionButtonPressed: () => Navigator.of(bottomSheetContext).pop(), ), ); }); @@ -302,13 +306,13 @@ class DEuroSavingsPage extends BasePage { titleText: title, titleIconPath: CryptoCurrency.deuro.iconPath, content: content, - isTwoAction: true, - rightButtonText: S.of(context).close, + footerType: FooterType.doubleActionButton, + doubleActionRightButtonText: S.of(context).close, rightActionButtonKey: ValueKey('deuro_page_tooltip_dialog_${key}_ok_button_key'), - actionRightButton: () => Navigator.of(bottomSheetContext).pop(), - leftButtonText: S.of(context).learn_more, + onRightActionButtonPressed: () => Navigator.of(bottomSheetContext).pop(), + doubleActionLeftButtonText: S.of(context).learn_more, leftActionButtonKey: ValueKey('deuro_page_tooltip_dialog_${key}_learn_more_button_key'), - actionLeftButton: onLearnMorePressed, + onLeftActionButtonPressed: onLearnMorePressed, ), ); } @@ -327,13 +331,13 @@ class DEuroSavingsPage extends BasePage { contentImage: 'assets/images/deuro_hero.png', contentImageSize: 200, content: S.of(context).deuro_savings_welcome_description, - isTwoAction: true, - rightButtonText: S.of(context).close, + footerType: FooterType.doubleActionButton, + doubleActionRightButtonText: S.of(context).close, rightActionButtonKey: ValueKey('deuro_page_tooltip_dialog_welcome_ok_button_key'), - actionRightButton: () => Navigator.of(bottomSheetContext).pop(), - leftButtonText: S.of(context).learn_more, + onRightActionButtonPressed: () => Navigator.of(bottomSheetContext).pop(), + doubleActionLeftButtonText: S.of(context).learn_more, leftActionButtonKey: ValueKey('deuro_page_tooltip_dialog_welcome_learn_more_button_key'), - actionLeftButton: () => launchUrlString("https://deuro.com/what-is-deuro.html"), + onLeftActionButtonPressed: () => launchUrlString("https://deuro.com/what-is-deuro.html"), showDisclaimerText: _dEuroViewModel.isFistTime, ), ); @@ -352,9 +356,9 @@ class DEuroSavingsPage extends BasePage { titleIconPath: CryptoCurrency.deuro.iconPath, contentImage: 'assets/images/deuro_not_enough_eth.png', content: S.of(context).deuro_tooltip_no_eth, - actionButtonKey: ValueKey('deuro_page_tooltip_dialog_no_eth_ok_button_key'), - actionButtonText: S.of(context).close, - actionButton: () => Navigator.of(bottomSheetContext).pop(), + singleActionButtonKey: ValueKey('deuro_page_tooltip_dialog_no_eth_ok_button_key'), + singleActionButtonText: S.of(context).close, + onSingleActionButtonPressed: () => Navigator.of(bottomSheetContext).pop(), footerType: FooterType.singleActionButton, ), ); } @@ -372,6 +376,8 @@ class DEuroSavingsPage extends BasePage { ? S.of(context).deuro_savings_available_to_add : S.of(context).deuro_savings_available_to_remove, balance: isAdding ? _dEuroViewModel.accountBalance : _dEuroViewModel.savingsBalance, + footerType: FooterType.none, + maxHeight: MediaQuery.of(context).size.height * 0.8, ), ); } diff --git a/lib/src/screens/integrations/deuro/widgets/savings_edit_sheet.dart b/lib/src/screens/integrations/deuro/widgets/savings_edit_sheet.dart index 11feb27f7..2c9b66d57 100644 --- a/lib/src/screens/integrations/deuro/widgets/savings_edit_sheet.dart +++ b/lib/src/screens/integrations/deuro/widgets/savings_edit_sheet.dart @@ -14,18 +14,18 @@ class SavingsEditSheet extends BaseBottomSheet { super.titleIconPath, this.balance, this.balanceTitle, + required super.footerType, required super.maxHeight, }); @override Widget contentWidget(BuildContext context) => SizedBox( - height: 600, + height: 500, child: _SavingsEditBody( balance: balance, balanceTitle: balanceTitle, ), ); - @override Widget footerWidget(BuildContext context) => SizedBox.shrink(); } diff --git a/lib/src/screens/receive/widgets/qr_widget.dart b/lib/src/screens/receive/widgets/qr_widget.dart index 267bcf13d..0018e632a 100644 --- a/lib/src/screens/receive/widgets/qr_widget.dart +++ b/lib/src/screens/receive/widgets/qr_widget.dart @@ -6,6 +6,7 @@ import 'package:cake_wallet/src/screens/receive/widgets/currency_input_field.dar import 'package:cake_wallet/themes/core/material_base_theme.dart'; import 'package:cake_wallet/generated/i18n.dart'; import 'package:cake_wallet/src/screens/receive/widgets/qr_image.dart'; +import 'package:cake_wallet/src/widgets/bottom_sheet/base_bottom_sheet_widget.dart'; import 'package:cake_wallet/src/widgets/bottom_sheet/info_bottom_sheet_widget.dart'; import 'package:cake_wallet/utils/address_formatter.dart'; import 'package:cake_wallet/utils/brightness_util.dart'; @@ -319,14 +320,13 @@ class QRWidget extends StatelessWidget { titleText: S.of(context).payjoin_unavailable_sheet_title, content: S.of(context).payjoin_unavailable_sheet_content, currentTheme: currentTheme, - isTwoAction: true, - leftButtonText: S.of(context).learn_more, - actionLeftButton: () => launchUrl( - Uri.parse("https://docs.cakewallet.com/cryptos/bitcoin/#payjoin"), - mode: LaunchMode.externalApplication, - ), - rightButtonText: S.of(context).ok, - actionRightButton: () => Navigator.of(context).pop(), + footerType: FooterType.doubleActionButton, + doubleActionLeftButtonText: S.of(context).learn_more, + onLeftActionButtonPressed: () => launchUrl( + Uri.parse("https://docs.cakewallet.com/cryptos/bitcoin/#payjoin"), + mode: LaunchMode.externalApplication), + doubleActionRightButtonText: S.of(context).ok, + onRightActionButtonPressed: () => Navigator.of(context).pop(), ), ); } diff --git a/lib/src/screens/send/send_page.dart b/lib/src/screens/send/send_page.dart index 9b6047cdc..d8ed4fe5e 100644 --- a/lib/src/screens/send/send_page.dart +++ b/lib/src/screens/send/send_page.dart @@ -17,12 +17,14 @@ import 'package:cake_wallet/src/widgets/adaptable_page_view.dart'; import 'package:cake_wallet/src/widgets/add_template_button.dart'; import 'package:cake_wallet/src/widgets/alert_with_one_action.dart'; import 'package:cake_wallet/src/widgets/alert_with_two_actions.dart'; +import 'package:cake_wallet/src/widgets/bottom_sheet/base_bottom_sheet_widget.dart'; import 'package:cake_wallet/src/widgets/bottom_sheet/confirm_sending_bottom_sheet_widget.dart'; import 'package:cake_wallet/src/widgets/bottom_sheet/info_bottom_sheet_widget.dart'; import 'package:cake_wallet/src/widgets/keyboard_done_button.dart'; import 'package:cake_wallet/src/widgets/picker.dart'; import 'package:cake_wallet/src/widgets/primary_button.dart'; import 'package:cake_wallet/src/widgets/scollable_with_bottom_section.dart'; +import 'package:cake_wallet/src/widgets/simple_checkbox.dart'; import 'package:cake_wallet/src/widgets/template_tile.dart'; import 'package:cake_wallet/src/widgets/trail_button.dart'; import 'package:cake_wallet/utils/payment_request.dart'; @@ -569,6 +571,7 @@ class SendPage extends BasePage { key: ValueKey('send_page_confirm_sending_dialog_key'), titleText: S.of(bottomSheetContext).confirm_transaction, currentTheme: currentTheme, + footerType: FooterType.slideActionButton, walletType: sendViewModel.walletType, titleIconPath: sendViewModel.selectedCryptoCurrency.iconPath, currency: sendViewModel.selectedCryptoCurrency, @@ -581,7 +584,7 @@ class SendPage extends BasePage { feeValue: sendViewModel.pendingTransaction!.feeFormatted, feeFiatAmount: sendViewModel.pendingTransactionFeeFiatAmountFormatted, outputs: sendViewModel.outputs, - onSlideComplete: () async { + onSlideActionComplete: () async { Navigator.of(bottomSheetContext).pop(true); sendViewModel.commitTransaction(context); }, @@ -611,48 +614,69 @@ class SendPage extends BasePage { bool showContactSheet = (newContactAddress != null && sendViewModel.showAddressBookPopup); - await showModalBottomSheet( - context: context, - isDismissible: false, - builder: (BuildContext bottomSheetContext) { - return showContactSheet && sendViewModel.ocpRequest == null - ? InfoBottomSheet( - currentTheme: currentTheme, - showDontAskMeCheckbox: true, - onCheckboxChanged: (value) => sendViewModel.setShowAddressBookPopup(!value), - titleText: S.of(bottomSheetContext).transaction_sent, - contentImage: 'assets/images/contact.png', - contentImageColor: Theme.of(context).colorScheme.onSurface, - content: S.of(bottomSheetContext).add_contact_to_address_book, - isTwoAction: true, - leftButtonText: 'No', - rightButtonText: 'Yes', - actionLeftButton: () { - Navigator.of(bottomSheetContext).pop(); - if (context.mounted) { - Navigator.of(context) - .pushNamedAndRemoveUntil(Routes.dashboard, (route) => false); - } - RequestReviewHandler.requestReview(); - newContactAddress = null; - }, - actionRightButton: () { - Navigator.of(bottomSheetContext).pop(); - RequestReviewHandler.requestReview(); - if (context.mounted) { - Navigator.of(context).pushNamed(Routes.addressBookAddContact, - arguments: newContactAddress); - } - newContactAddress = null; - }, - ) + await showModalBottomSheet( + context: context, + isDismissible: false, + builder: (BuildContext bottomSheetContext) { + return showContactSheet && sendViewModel.ocpRequest == null + ? InfoBottomSheet( + currentTheme: currentTheme, + footerType: FooterType.doubleActionButton, + titleText: S.of(bottomSheetContext).transaction_sent, + contentImage: 'assets/images/contact.png', + contentImageColor: Theme.of(context).colorScheme.onSurface, + content: S.of(bottomSheetContext).add_contact_to_address_book, + bottomActionPanel: Padding( + padding: const EdgeInsets.only(left: 34.0), + child: Row( + children: [ + SimpleCheckbox( + onChanged: (value) => + sendViewModel.setShowAddressBookPopup(!value)), + const SizedBox(width: 8), + Text( + 'Don’t ask me next time', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 14, + fontFamily: 'Lato', + fontWeight: FontWeight.w500, + color: Theme.of(context).textTheme.titleLarge!.color, + decoration: TextDecoration.none, + ), + ), + ], + ), + ), + doubleActionLeftButtonText: 'No', + doubleActionRightButtonText: 'Yes', + onLeftActionButtonPressed: () { + Navigator.of(bottomSheetContext).pop(); + if (context.mounted) { + Navigator.of(context) + .pushNamedAndRemoveUntil(Routes.dashboard, (route) => false); + } + RequestReviewHandler.requestReview(); + newContactAddress = null; + }, + onRightActionButtonPressed: () { + Navigator.of(bottomSheetContext).pop(); + RequestReviewHandler.requestReview(); + if (context.mounted) { + Navigator.of(context).pushNamed(Routes.addressBookAddContact, + arguments: newContactAddress); + } + newContactAddress = null; + }, + ) : InfoBottomSheet( currentTheme: currentTheme, + footerType: FooterType.singleActionButton, titleText: S.of(bottomSheetContext).transaction_sent, contentImage: 'assets/images/birthday_cake.png', - actionButtonText: S.of(bottomSheetContext).close, - actionButtonKey: ValueKey('send_page_sent_dialog_ok_button_key'), - actionButton: () { + singleActionButtonText: S.of(bottomSheetContext).close, + singleActionButtonKey: ValueKey('send_page_transaction_sent_button_key'), + onSingleActionButtonPressed: () { Navigator.of(bottomSheetContext).pop(); Future.delayed(Duration.zero, () { if (context.mounted) { @@ -710,14 +734,14 @@ class SendPage extends BasePage { dialogContext = context; return InfoBottomSheet( currentTheme: currentTheme, + footerType: FooterType.singleActionButton, titleText: S.of(context).proceed_on_device, contentImage: 'assets/images/hardware_wallet/ledger_nano_x.png', contentImageColor: Theme.of(context).colorScheme.onSurface, content: S.of(context).proceed_on_device_description, - isTwoAction: false, - actionButtonText: S.of(context).cancel, - actionButton: () { + singleActionButtonText: S.of(context).cancel, + onSingleActionButtonPressed: () { sendViewModel.state = InitialExecutionState(); Navigator.of(context).pop(); }, diff --git a/lib/src/screens/transaction_details/rbf_details_page.dart b/lib/src/screens/transaction_details/rbf_details_page.dart index a3e1884cd..d83ea5b81 100644 --- a/lib/src/screens/transaction_details/rbf_details_page.dart +++ b/lib/src/screens/transaction_details/rbf_details_page.dart @@ -8,6 +8,7 @@ import 'package:cake_wallet/src/screens/transaction_details/transaction_expandab import 'package:cake_wallet/src/screens/transaction_details/widgets/textfield_list_row.dart'; import 'package:cake_wallet/src/widgets/alert_with_one_action.dart'; import 'package:cake_wallet/src/widgets/alert_with_two_actions.dart'; +import 'package:cake_wallet/src/widgets/bottom_sheet/base_bottom_sheet_widget.dart'; import 'package:cake_wallet/src/widgets/bottom_sheet/confirm_sending_bottom_sheet_widget.dart'; import 'package:cake_wallet/src/widgets/bottom_sheet/info_bottom_sheet_widget.dart'; import 'package:cake_wallet/src/widgets/list_row.dart'; @@ -200,7 +201,9 @@ class RBFDetailsPage extends BasePage { feeValue: transactionDetailsViewModel.sendViewModel.pendingTransaction!.feeFormatted, feeFiatAmount: transactionDetailsViewModel.sendViewModel.pendingTransactionFeeFiatAmountFormatted, outputs: transactionDetailsViewModel.sendViewModel.outputs, - onSlideComplete: () async { + footerType: FooterType.slideActionButton, + accessibleNavigationModeSlideActionButtonText: S.of(context).send, + onSlideActionComplete: () async { Navigator.of(bottomSheetContext).pop(); await transactionDetailsViewModel.sendViewModel.commitTransaction(context); try { diff --git a/lib/src/widgets/alert_background.dart b/lib/src/widgets/alert_background.dart index cc5898961..6efe49f4a 100644 --- a/lib/src/widgets/alert_background.dart +++ b/lib/src/widgets/alert_background.dart @@ -3,28 +3,31 @@ import 'package:cake_wallet/utils/responsive_layout_util.dart'; import 'package:flutter/material.dart'; class AlertBackground extends StatelessWidget { - AlertBackground({required this.child}); + const AlertBackground({Key? key, required this.child, this.dismissible = false}); final Widget child; + final bool dismissible; @override Widget build(BuildContext context) { return Scaffold( resizeToAvoidBottomInset: false, backgroundColor: Colors.transparent, - body: Container( - height: double.infinity, - width: double.infinity, - color: Colors.transparent, + body: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: dismissible ? () => Navigator.of(context).pop() : null, child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 3.0, sigmaY: 3.0), + filter: ImageFilter.blur(sigmaX: 3, sigmaY: 3), child: Container( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surface.withOpacity(0.8)), + decoration: BoxDecoration(color: Theme.of(context).colorScheme.surface.withAlpha(200)), child: Center( - child: Container( - width: ResponsiveLayoutUtilBase.kDesktopMaxWidthConstraint, - child: child, + child: GestureDetector( + onTap: () {}, + child: ConstrainedBox( + constraints: + BoxConstraints(maxWidth: ResponsiveLayoutUtilBase.kDesktopMaxWidthConstraint), + child: child, + ), ), ), ), diff --git a/lib/src/widgets/base_text_form_field.dart b/lib/src/widgets/base_text_form_field.dart index bc2f69287..b51ff200b 100644 --- a/lib/src/widgets/base_text_form_field.dart +++ b/lib/src/widgets/base_text_form_field.dart @@ -43,7 +43,9 @@ class BaseTextFormField extends StatelessWidget { this.onFieldSubmitted, this.hasUnderlineBorder = false, this.borderWidth = 1.0, - super.key, + this.prefixIconConstraints, + this.suffixIconConstraints, + super.key, this.suffixText, }); final TextEditingController? controller; @@ -70,6 +72,9 @@ class BaseTextFormField extends StatelessWidget { final bool readOnly; final bool? enableInteractiveSelection; final String? initialValue; + final BoxConstraints? prefixIconConstraints; + final BoxConstraints? suffixIconConstraints; + final String? suffixText; final void Function(String)? onSubmit; final bool obscureText; final bool? autofocus; @@ -120,8 +125,12 @@ class BaseTextFormField extends StatelessWidget { alignLabelWithHint: alignLabelWithHint, contentPadding: contentPadding, floatingLabelBehavior: FloatingLabelBehavior.never, + prefixIconConstraints: prefixIconConstraints ?? + const BoxConstraints(minWidth: 0, minHeight: 0), prefix: prefix, prefixIcon: prefixIcon, + suffixIconConstraints: suffixIconConstraints ?? + const BoxConstraints(minWidth: 0, minHeight: 0), suffix: suffix, suffixIcon: suffixIcon, filled: !hasUnderlineBorder, diff --git a/lib/src/widgets/bottom_sheet/base_bottom_sheet_widget.dart b/lib/src/widgets/bottom_sheet/base_bottom_sheet_widget.dart index bcd47b0bf..1c656fb51 100644 --- a/lib/src/widgets/bottom_sheet/base_bottom_sheet_widget.dart +++ b/lib/src/widgets/bottom_sheet/base_bottom_sheet_widget.dart @@ -1,62 +1,53 @@ +import 'package:cake_wallet/src/widgets/primary_button.dart'; +import 'package:cake_wallet/src/widgets/standard_slide_button_widget.dart'; +import 'package:cake_wallet/themes/core/material_base_theme.dart'; import 'package:flutter/material.dart'; +enum FooterType { none, slideActionButton, singleActionButton, doubleActionButton } + abstract class BaseBottomSheet extends StatelessWidget { + const BaseBottomSheet({ + super.key, + required this.titleText, + this.titleIconPath, + required this.footerType, + this.currentTheme, + this.slideActionButtonText, + this.onSlideActionComplete, + this.singleActionButtonText, + this.accessibleNavigationModeSlideActionButtonText, + this.onSingleActionButtonPressed, + this.singleActionButtonKey, + this.doubleActionLeftButtonText, + this.doubleActionRightButtonText, + this.onLeftActionButtonPressed, + this.onRightActionButtonPressed, + this.leftActionButtonKey, + this.rightActionButtonKey, + required this.maxHeight, + }) : assert(footerType == FooterType.none || currentTheme != null, + 'currentTheme is required unless footerType is none'); + final String titleText; final String? titleIconPath; + final MaterialThemeBase? currentTheme; + final FooterType footerType; + final String? slideActionButtonText; + final VoidCallback? onSlideActionComplete; + final String? singleActionButtonText; + final String? accessibleNavigationModeSlideActionButtonText; + final VoidCallback? onSingleActionButtonPressed; + final Key? singleActionButtonKey; + final String? doubleActionLeftButtonText; + final String? doubleActionRightButtonText; + final VoidCallback? onLeftActionButtonPressed; + final VoidCallback? onRightActionButtonPressed; + final Key? leftActionButtonKey; + final Key? rightActionButtonKey; final double maxHeight; - const BaseBottomSheet({required this.titleText, this.titleIconPath, this.maxHeight = 900}); - - Widget headerWidget(BuildContext context) { - return Column( - children: [ - Padding( - padding: const EdgeInsets.symmetric(vertical: 16), - child: Row( - children: [ - const Spacer(flex: 4), - Expanded( - flex: 2, - child: Container( - height: 6, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(4), - color: Theme.of(context).colorScheme.onSurface, - ), - ), - ), - const Spacer(flex: 4), - ], - ), - ), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - if (titleIconPath != null) - Image.asset(titleIconPath!, height: 24, width: 24, excludeFromSemantics: true) - else - Container(), - const SizedBox(width: 6), - Text( - titleText, - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.titleLarge!.copyWith( - fontSize: 20, - fontWeight: FontWeight.w600, - decoration: TextDecoration.none, - ), - ), - ], - ), - const SizedBox(height: 24), - ], - ); - } - Widget contentWidget(BuildContext context); - Widget footerWidget(BuildContext context); - @override Widget build(BuildContext context) { return ConstrainedBox( @@ -69,13 +60,106 @@ abstract class BaseBottomSheet extends StatelessWidget { child: Column( mainAxisSize: MainAxisSize.min, children: [ - headerWidget(context), + _buildHeader(context), contentWidget(context), - footerWidget(context), + _buildFooter(context), ], ), ), ), ); } + + Widget _buildHeader(BuildContext context) => Column( + children: [ + const SizedBox(height: 12), + Container( + width: 64, + height: 5, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(4), + color: Theme.of(context).colorScheme.onSurface, + ), + ), + const SizedBox(height: 20), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (titleIconPath != null) ...[ + Image.asset(titleIconPath!, height: 24, width: 24), + const SizedBox(width: 6), + ], + Text( + titleText, + style: Theme.of(context).textTheme.titleLarge!.copyWith( + fontSize: 20, + fontWeight: FontWeight.w600, + decoration: TextDecoration.none, + ), + ), + ], + ), + const SizedBox(height: 13), + ], + ); + + Widget _buildFooter(BuildContext context) { + switch (footerType) { + case FooterType.none: + return const SizedBox.shrink(); + + case FooterType.slideActionButton: + return Padding( + padding: const EdgeInsets.fromLTRB(40, 12, 40, 34), + child: StandardSlideButton( + buttonText: slideActionButtonText ?? '', + onSlideComplete: onSlideActionComplete ?? () {}, + currentTheme: currentTheme!, + accessibleNavigationModeButtonText: accessibleNavigationModeSlideActionButtonText ?? '', + ), + ); + + case FooterType.singleActionButton: + return Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 34), + child: LoadingPrimaryButton( + key: singleActionButtonKey, + text: singleActionButtonText ?? '', + onPressed: onSingleActionButtonPressed ?? () {}, + color: Theme.of(context).colorScheme.primary, + textColor: Theme.of(context).colorScheme.onPrimary, + isLoading: false, + isDisabled: false, + ), + ); + + case FooterType.doubleActionButton: + return Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 34), + child: Row( + children: [ + Expanded( + child: PrimaryButton( + key: leftActionButtonKey, + text: doubleActionLeftButtonText ?? '', + onPressed: onLeftActionButtonPressed, + color: Theme.of(context).colorScheme.surfaceContainer, + textColor: Theme.of(context).colorScheme.onSecondaryContainer + ), + ), + const SizedBox(width: 12), + Expanded( + child: PrimaryButton( + key: rightActionButtonKey, + text: doubleActionRightButtonText ?? '', + onPressed: onRightActionButtonPressed, + color: Theme.of(context).colorScheme.primary, + textColor: Theme.of(context).colorScheme.onPrimary, + ), + ), + ], + ), + ); + } + } } diff --git a/lib/src/widgets/bottom_sheet/cake_pay_card_info_bottom_sheet_widget.dart b/lib/src/widgets/bottom_sheet/cake_pay_card_info_bottom_sheet_widget.dart new file mode 100644 index 000000000..900f59bb9 --- /dev/null +++ b/lib/src/widgets/bottom_sheet/cake_pay_card_info_bottom_sheet_widget.dart @@ -0,0 +1,352 @@ +import 'package:cake_wallet/cake_pay/src/widgets/cake_pay_alert_modal.dart'; +import 'package:cake_wallet/cake_pay/src/widgets/flip_card_widget.dart'; +import 'package:cake_wallet/cake_pay/src/widgets/link_extractor.dart'; +import 'package:cake_wallet/generated/i18n.dart'; +import 'package:cake_wallet/palette.dart'; +import 'package:cake_wallet/themes/core/material_base_theme.dart'; +import 'package:cake_wallet/utils/image_utill.dart'; +import 'package:cake_wallet/utils/show_pop_up.dart'; +import 'package:flutter/material.dart'; + +import 'base_bottom_sheet_widget.dart'; + +class CakePayCardInfoBottomSheet extends BaseBottomSheet { + CakePayCardInfoBottomSheet({ + required String titleText, + required MaterialThemeBase currentTheme, + required FooterType footerType, + String? titleIconPath, + String? singleActionButtonText, + VoidCallback? onSingleActionButtonPressed, + Key? singleActionButtonKey, + String? doubleActionLeftButtonText, + String? doubleActionRightButtonText, + VoidCallback? onLeftActionButtonPressed, + VoidCallback? onRightActionButtonPressed, + Key? rightActionButtonKey, + Key? leftActionButtonKey, + required this.onUpdateBalancePressed, + required this.isReloadable, + required this.balance, + this.contentImage, + this.howToUse, + this.applyBoxShadow = false, + Key? key, + }) : _currentTheme = currentTheme, + super( + titleText: titleText, + maxHeight: 900, + titleIconPath: titleIconPath, + currentTheme: currentTheme, + footerType: footerType, + singleActionButtonText: singleActionButtonText, + onSingleActionButtonPressed: onSingleActionButtonPressed, + singleActionButtonKey: singleActionButtonKey, + doubleActionLeftButtonText: doubleActionLeftButtonText, + doubleActionRightButtonText: doubleActionRightButtonText, + onLeftActionButtonPressed: onLeftActionButtonPressed, + onRightActionButtonPressed: onRightActionButtonPressed, + leftActionButtonKey: leftActionButtonKey, + rightActionButtonKey: rightActionButtonKey, + key: key); + + final VoidCallback onUpdateBalancePressed; + final MaterialThemeBase _currentTheme; + final String? contentImage; + final String? howToUse; + final bool isReloadable; + final String balance; + final bool applyBoxShadow; + + final _cardKey = GlobalKey(); + + @override + Widget contentWidget(BuildContext context) { + final itemTitleTextStyle = Theme.of(context).textTheme.bodyMedium!.copyWith( + fontSize: 16, + fontWeight: FontWeight.w500, + decoration: TextDecoration.none, + ); + final itemSubTitleTextStyle = Theme.of(context).textTheme.bodySmall!.copyWith( + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.onSurfaceVariant, + decoration: TextDecoration.none, + ); + + final tileBackgroundColor = Theme.of(context).colorScheme.surfaceContainer; + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (contentImage != null) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 32), + child: Stack( + children: [ + Padding( + padding: const EdgeInsets.only(top: 0, left: 16, right: 16, bottom: 16), + child: AspectRatio( + aspectRatio: 1.6, + child: FlipCard( + key: _cardKey, + flipOnTouch: true, + front: _buildCardImage(context, contentImage!, applyBoxShadow), + back: _buildBarcodeSide(context, + cardNumber: '6006491979836784204', pin: '4782'), + ), + ), + ), + Positioned( + bottom: 0, + right: 0, + child: GestureDetector( + onTap: () => _cardKey.currentState?.toggleCard(), + child: Container( + height: 43, + width: 43, + decoration: BoxDecoration( + border: Border.all(color: Colors.white, width: 1), + color: Colors.white.withAlpha(75), + shape: BoxShape.circle, + ), + child: Transform.scale( + scale: .8, + child: const ImageIcon( + AssetImage('assets/images/transfer.png'), + color: Colors.white, + ), + )), + ), + ), + ], + ), + ), + Container(), + Text( + 'Tap card to show details', + style: itemTitleTextStyle.copyWith(fontSize: 12, fontWeight: FontWeight.w700), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Column( + children: [ + const SizedBox(height: 34), + CakePayInfoTile( + isReloadable: isReloadable, + itemValue: balance, + itemTitleTextStyle: itemTitleTextStyle, + itemSubTitleTextStyle: itemSubTitleTextStyle, + tileBackgroundColor: tileBackgroundColor), + const SizedBox(height: 8), + _HowToUseTile(howToUse: howToUse ?? '', tileBackgroundColor: tileBackgroundColor), + const SizedBox(height: 40), + ], + ), + ), + ], + ); + } +} + +class _HowToUseTile extends StatelessWidget { + const _HowToUseTile({required this.howToUse, required this.tileBackgroundColor}); + + final String howToUse; + final Color tileBackgroundColor; + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: () => _showHowToUseCard(context: context, howToUse: howToUse), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + decoration: + BoxDecoration(borderRadius: BorderRadius.circular(10), color: tileBackgroundColor), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + S.of(context).how_to_use_card, + style: Theme.of(context).textTheme.bodyLarge), + Icon( + Icons.chevron_right_rounded, + color: Theme.of(context).textTheme.titleLarge!.color!, + ), + ], + )), + ); + } +} + +class CakePayInfoTile extends StatelessWidget { + const CakePayInfoTile({ + super.key, + required this.isReloadable, + required this.itemValue, + required this.itemTitleTextStyle, + this.itemSubTitle, + required this.itemSubTitleTextStyle, + required this.tileBackgroundColor, + }); + + final bool isReloadable; + final String itemValue; + final TextStyle itemTitleTextStyle; + final String? itemSubTitle; + final TextStyle itemSubTitleTextStyle; + final Color tileBackgroundColor; + + @override + Widget build(BuildContext context) { + return Semantics( + container: true, + label: isReloadable ? 'Balance' : 'Total Value', + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), + decoration: + BoxDecoration(borderRadius: BorderRadius.circular(10), color: tileBackgroundColor), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(isReloadable ? 'Balance' : 'Total Value', style: itemTitleTextStyle), + Text(itemValue, + style: itemTitleTextStyle.copyWith(fontSize: 18, fontWeight: FontWeight.w600)), + ], + ), + const SizedBox(height: 14), + Container( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8), + child: Text(isReloadable ? 'Top Up Balance' : 'Manually update Balance', + style: itemTitleTextStyle.copyWith(fontSize: 12, fontWeight: FontWeight.w600)), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(18), + color: Theme.of(context).dialogBackgroundColor)), + const SizedBox(height: 4), + ], + ), + ), + ); + } +} + +void _showHowToUseCard({required BuildContext context, String? howToUse}) { + showPopUp( + context: context, + builder: (BuildContext context) { + return CakePayAlertModal( + title: S.of(context).how_to_use_card, + content: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + ClickableLinksText( + text: howToUse ?? '', + textStyle: Theme.of(context).textTheme.bodyMedium!, + linkStyle: TextStyle( + color: Theme.of(context).textTheme.titleLarge!.color!, + fontSize: 18, + fontStyle: FontStyle.italic, + fontWeight: FontWeight.w400, + ), + ), + ]), + actionTitle: S.current.got_it, + ); + }); +} + +Widget _buildCardImage(BuildContext ctx, String path, bool addShadow) { + final border = BorderRadius.circular(10); + + return Container( + decoration: addShadow + ? BoxDecoration( + borderRadius: border, + boxShadow: [ + BoxShadow(color: Colors.black.withAlpha(150), blurRadius: 5) + ], + ) + : null, + child: ClipRRect( + borderRadius: border, + child: Stack( + fit: StackFit.expand, + children: [ + Container(color: Theme.of(ctx).cardColor.withAlpha(200)), + ImageUtil.getImageFromPath( + imagePath: path, + fit: BoxFit.cover, + ), + ], + ), + ), + ); +} + +Widget _buildBarcodeSide(BuildContext context, {required String cardNumber, required String pin}) => + SizedBox.expand( + child: Container( + padding: const EdgeInsets.only(top: 16, left: 24, right: 24, bottom: 34), + decoration: BoxDecoration( + color: Theme.of(context).cardColor.withAlpha(200), + borderRadius: BorderRadius.circular(10), + boxShadow: [ + BoxShadow(color: Colors.black.withAlpha(150), blurRadius: 5) + ], + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Expanded( + child: Container( + color: Theme.of(context).textTheme.titleLarge!.color!.withOpacity(.1), + + ), + ), + const SizedBox(height: 12), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Gift Card Number', + style: TextStyle( + fontFamily: 'Lato', + fontSize: 10, + fontWeight: FontWeight.w500, + color: Color.fromRGBO(207, 207, 207, 1))), + const SizedBox(height: 4), + Text(cardNumber, + style: TextStyle( + fontFamily: 'Lato', + fontSize: 12, + fontWeight: FontWeight.w900, + color: Color.fromRGBO(146, 146, 146, 1))), + ], + ), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('PIN Number', + style: TextStyle( + fontFamily: 'Lato', + fontSize: 10, + fontWeight: FontWeight.w500, + color: Color.fromRGBO(207, 207, 207, 1))), + const SizedBox(height: 4), + Text(pin, + style: TextStyle( + fontFamily: 'Lato', + fontSize: 12, + fontWeight: FontWeight.w900, + color: Color.fromRGBO(146, 146, 146, 1))), + ], + ), + ], + ), + + ], + ), + ), + ); diff --git a/lib/src/widgets/bottom_sheet/cake_pay_transaction_sent_bottom_sheet.dart b/lib/src/widgets/bottom_sheet/cake_pay_transaction_sent_bottom_sheet.dart new file mode 100644 index 000000000..6be6d389f --- /dev/null +++ b/lib/src/widgets/bottom_sheet/cake_pay_transaction_sent_bottom_sheet.dart @@ -0,0 +1,239 @@ +import 'package:cake_wallet/generated/i18n.dart'; +import 'package:cake_wallet/src/widgets/primary_button.dart'; +import 'package:cake_wallet/utils/image_utill.dart'; +import 'package:cake_wallet/view_model/send/output.dart'; +import 'package:cw_core/crypto_currency.dart'; +import 'package:flutter/material.dart'; + +class CakePayTransactionSentBottomSheet extends StatelessWidget { + const CakePayTransactionSentBottomSheet({ + super.key, + required this.titleText, + required this.currency, + required this.amount, + required this.amountValue, + required this.fiatAmountValue, + required this.output, + required this.fee, + required this.feeValue, + required this.feeFiatAmount, + this.titleIconWidget, + required this.quantity, + required this.onClose, + required this.paymentId, + required this.paymentIdValue, + }); + + final String titleText; + final Widget? titleIconWidget; + final CryptoCurrency currency; + final String amount; + final String amountValue; + final String fiatAmountValue; + final Output output; + final String fee; + final String feeValue; + final String feeFiatAmount; + final String quantity; + final VoidCallback onClose; + final String paymentId; + final String paymentIdValue; + + TextStyle _titleStyle(BuildContext ctx) => Theme.of(ctx).textTheme.bodyLarge!; + + TextStyle _valueStyle(BuildContext ctx) => Theme.of(ctx).textTheme.titleMedium!.copyWith( + fontSize: 18, + fontWeight: FontWeight.w600, + ); + + TextStyle _subStyle(BuildContext ctx) => Theme.of(ctx).textTheme.labelSmall!.copyWith( + color: Theme.of(ctx).colorScheme.onSurfaceVariant, + ); + + Widget _buildHeader(BuildContext ctx) => Column( + children: [ + const SizedBox(height: 12), + Container( + width: 64, + height: 5, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(4), + color: Theme.of(ctx).colorScheme.onSurface, + ), + ), + const SizedBox(height: 20), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (titleIconWidget != null) titleIconWidget!, + const SizedBox(width: 6), + Text( + titleText, + style: Theme.of(ctx).textTheme.titleLarge!.copyWith( + fontSize: 20, + fontWeight: FontWeight.w600, + decoration: TextDecoration.none, + ), + ), + ], + ), + const SizedBox(height: 12), + ], + ); + + Widget _buildBody(BuildContext context) => Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Column( + children: [ + _StandardTile( + itemTitle: amount, + titleStyle: _titleStyle(context), + itemValue: '$amountValue ${currency.title}', + itemValueStyle: _valueStyle(context), + itemSubTitle: fiatAmountValue, + itemSubTitleStyle: _subStyle(context), + ), + const SizedBox(height: 8), + _StandardTile( + itemTitle: fee, + titleStyle: _titleStyle(context), + itemValue: feeValue, + itemValueStyle: _valueStyle(context), + itemSubTitle: feeFiatAmount, + itemSubTitleStyle: _subStyle(context), + ), + const SizedBox(height: 8), + _StandardTile( + itemTitle: output.parsedAddress.profileName, + titleStyle: _titleStyle(context), + itemValue: output.fiatAmount, + itemValueStyle: _valueStyle(context), + itemSubTitle: quantity, + itemSubTitleStyle: _subStyle(context), + imagePath: output.parsedAddress.profileImageUrl, + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), + child: Column( + children: [ + Text(paymentId + ': ' + paymentIdValue, + style: Theme.of(context).textTheme.bodyLarge, textAlign: TextAlign.center), + const SizedBox(height: 18), + Image.asset('assets/images/envelope.png'), + const SizedBox(height: 18), + Text( + S.of(context).cake_pay_card_email_delivered_message, + style: Theme.of(context).textTheme.bodyLarge, + textAlign: TextAlign.center, + ), + const SizedBox(height: 24), + PrimaryButton( + text: S.of(context).close, + color: Theme.of(context).colorScheme.primary, + textColor: Theme.of(context).colorScheme.onPrimaryContainer, + onPressed: onClose, + ), + ], + ), + ), + ], + ), + ); + + @override + Widget build(BuildContext context) { + final maxHeight = MediaQuery.of(context).size.height * 0.9; + + return ClipRRect( + borderRadius: const BorderRadius.vertical(top: Radius.circular(30)), + child: Material( + color: Theme.of(context).colorScheme.surface, + child: ConstrainedBox( + constraints: BoxConstraints(maxHeight: maxHeight), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _buildHeader(context), + _buildBody(context), + ], + ), + ), + ), + ), + ); + } +} + +class _StandardTile extends StatelessWidget { + const _StandardTile({ + required this.itemTitle, + required this.titleStyle, + required this.itemValue, + required this.itemValueStyle, + this.itemSubTitle, + this.itemSubTitleStyle, + this.imagePath, + }); + + final String itemTitle; + final TextStyle titleStyle; + final String itemValue; + final TextStyle itemValueStyle; + final String? itemSubTitle; + final TextStyle? itemSubTitleStyle; + final String? imagePath; + + @override + Widget build(BuildContext context) { + return Semantics( + container: true, + label: itemTitle, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10), + color: Theme.of(context).colorScheme.surfaceContainerLowest.withAlpha(80)), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Row( + children: [ + if (imagePath != null) + Padding( + padding: const EdgeInsets.only(right: 8), + child: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: ImageUtil.getImageFromPath( + imagePath: imagePath!, height: 40, width: 40), + ), + ), + Flexible( + child: Padding( + padding: const EdgeInsets.only(right: 8), + child: Text( + itemTitle, + style: titleStyle, + overflow: TextOverflow.ellipsis, + maxLines: 1, + softWrap: false, + )), + ), + ], + ), + ), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + mainAxisSize: MainAxisSize.min, + children: [ + Text(itemValue, style: itemValueStyle.copyWith(height: 1.0)), + if (itemSubTitle != null) Text(itemSubTitle!, style: itemSubTitleStyle), + ], + ), + ], + ), + ), + ); + } +} diff --git a/lib/src/widgets/bottom_sheet/confirm_sending_bottom_sheet_widget.dart b/lib/src/widgets/bottom_sheet/confirm_sending_bottom_sheet_widget.dart index a7ed6e45f..a8c643ac4 100644 --- a/lib/src/widgets/bottom_sheet/confirm_sending_bottom_sheet_widget.dart +++ b/lib/src/widgets/bottom_sheet/confirm_sending_bottom_sheet_widget.dart @@ -1,39 +1,29 @@ import 'package:cake_wallet/generated/i18n.dart'; import 'package:cake_wallet/src/widgets/standard_slide_button_widget.dart'; import 'package:cake_wallet/themes/core/material_base_theme.dart'; +import 'package:cake_wallet/themes/utils/custom_theme_colors.dart'; import 'package:cake_wallet/utils/address_formatter.dart'; +import 'package:cake_wallet/utils/image_utill.dart'; +import 'package:cake_wallet/view_model/cake_pay/cake_pay_buy_card_view_model.dart'; import 'package:cake_wallet/view_model/send/output.dart'; import 'package:cw_core/crypto_currency.dart'; import 'package:cw_core/pending_transaction.dart'; import 'package:cw_core/wallet_type.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_mobx/flutter_mobx.dart'; import 'base_bottom_sheet_widget.dart'; class ConfirmSendingBottomSheet extends BaseBottomSheet { - final CryptoCurrency currency; - final MaterialThemeBase currentTheme; - final String? paymentId; - final String? paymentIdValue; - final String? expirationTime; - final String amount; - final String amountValue; - final String fiatAmountValue; - final String fee; - final String feeValue; - final String feeFiatAmount; - final String? explanation; - final List outputs; - final VoidCallback onSlideComplete; - final WalletType walletType; - final PendingChange? change; - final bool isOpenCryptoPay; - ConfirmSendingBottomSheet({ required String titleText, + required MaterialThemeBase currentTheme, + required FooterType footerType, String? titleIconPath, + String? slideActionButtonText, + VoidCallback? onSlideActionComplete, + String? accessibleNavigationModeSlideActionButtonText, required this.currency, - required this.currentTheme, this.paymentId, this.paymentIdValue, this.expirationTime, @@ -44,14 +34,45 @@ class ConfirmSendingBottomSheet extends BaseBottomSheet { required this.feeValue, required this.feeFiatAmount, required this.outputs, - required this.onSlideComplete, required this.walletType, this.change, this.explanation, this.isOpenCryptoPay = false, + this.cakePayBuyCardViewModel, + this.quantity, Key? key, }) : showScrollbar = outputs.length > 3, - super(titleText: titleText, titleIconPath: titleIconPath); + _currentTheme = currentTheme, + super( + titleText: titleText, + maxHeight: 900, + titleIconPath: titleIconPath, + currentTheme: currentTheme, + footerType: footerType, + slideActionButtonText: slideActionButtonText ?? 'Swipe to send', + onSlideActionComplete: onSlideActionComplete, + accessibleNavigationModeSlideActionButtonText: + accessibleNavigationModeSlideActionButtonText, + key: key); + + final CryptoCurrency currency; + final MaterialThemeBase _currentTheme; + final String? paymentId; + final String? paymentIdValue; + final String? expirationTime; + final String amount; + final String amountValue; + final String fiatAmountValue; + final String fee; + final String feeValue; + final String feeFiatAmount; + final List outputs; + final WalletType walletType; + final PendingChange? change; + final bool isOpenCryptoPay; + final CakePayBuyCardViewModel? cakePayBuyCardViewModel; + final String? quantity; + final String? explanation; final bool showScrollbar; final ScrollController scrollController = ScrollController(); @@ -69,25 +90,32 @@ class ConfirmSendingBottomSheet extends BaseBottomSheet { decoration: TextDecoration.none, ); - final tileBackgroundColor = Theme.of(context).colorScheme.surfaceContainer; + final tileBackgroundColor = _currentTheme.isDark + ? CustomThemeColors.backgroundGradientColorDark.withAlpha(140) + : CustomThemeColors.cardGradientColorPrimaryLight; Widget content = Padding( padding: EdgeInsets.fromLTRB(8, 0, showScrollbar ? 16 : 8, 8), child: Column( children: [ - if (paymentId != null && paymentIdValue != null) + if (paymentId != null && paymentIdValue != null && cakePayBuyCardViewModel != null) Padding( padding: const EdgeInsets.only(bottom: 8), - child: AddressTile( - itemTitle: paymentId!, - itemTitleTextStyle: itemTitleTextStyle, - walletType: walletType, - isBatchSending: false, - amount: '', - address: paymentIdValue!, - itemSubTitleTextStyle: itemSubTitleTextStyle, - tileBackgroundColor: tileBackgroundColor, - ), + child: Observer( + builder: (_) => AddressTile( + itemTitle: paymentId!, + itemTitleTextStyle: itemTitleTextStyle, + amountTextStyle: itemSubTitleTextStyle, + walletType: walletType, + amount: expirationTime != null + ? S.current.offer_expires_in + + ' ${cakePayBuyCardViewModel!.formattedRemainingTime}' + : null, + address: paymentIdValue!, + itemSubTitleTextStyle: itemSubTitleTextStyle, + tileBackgroundColor: tileBackgroundColor, + applyAddressFormatting: false, + )), ), if (explanation != null) Padding( @@ -128,12 +156,13 @@ class ConfirmSendingBottomSheet extends BaseBottomSheet { final bool isBatchSending = outputs.length > 1; final item = outputs[index]; final contactName = item.parsedAddress.name; + final isCakePayName = contactName == 'Cake Pay'; final batchContactTitle = '${index + 1}/${outputs.length} - ${contactName.isEmpty ? 'Address' : contactName}'; final _address = item.isParsedAddress ? item.extractedAddress : item.address; final _amount = item.cryptoAmount.replaceAll(',', '.') + ' ${currency.title}'; - return isBatchSending || contactName.isNotEmpty - ? AddressExpansionTile( + return isBatchSending || (contactName.isNotEmpty && !isCakePayName) + ? ExpansionAddressTile( contactType: isOpenCryptoPay ? 'Open CryptoPay' : S.of(context).contact, name: isBatchSending ? batchContactTitle : contactName, address: _address, @@ -145,12 +174,15 @@ class ConfirmSendingBottomSheet extends BaseBottomSheet { tileBackgroundColor: tileBackgroundColor, ) : AddressTile( - itemTitle: S.of(context).address, + itemTitle: isCakePayName + ? item.parsedAddress.profileName + : S.of(context).address, + imagePath: isCakePayName ? item.parsedAddress.profileImageUrl : null, itemTitleTextStyle: itemTitleTextStyle, - isBatchSending: isBatchSending, walletType: walletType, - amount: _amount, + amount: isCakePayName ? item.fiatAmount : _amount, address: _address, + itemSubTitle: isCakePayName ? quantity : null, itemSubTitleTextStyle: itemSubTitleTextStyle, tileBackgroundColor: tileBackgroundColor, ); @@ -159,7 +191,7 @@ class ConfirmSendingBottomSheet extends BaseBottomSheet { if (change != null) Padding( padding: const EdgeInsets.only(top: 8), - child: AddressExpansionTile( + child: ExpansionAddressTile( contactType: 'Change', name: S.of(context).send_change_to_you, address: change!.address, @@ -195,31 +227,6 @@ class ConfirmSendingBottomSheet extends BaseBottomSheet { return content; } } - - @override - Widget footerWidget(BuildContext context) { - return Container( - padding: const EdgeInsets.fromLTRB(40, 12, 40, 34), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surface, - boxShadow: [ - if (showScrollbar) - BoxShadow( - color: Theme.of(context).colorScheme.outlineVariant, - spreadRadius: 2, - blurRadius: 10, - offset: const Offset(0, 0), - ), - ], - ), - child: StandardSlideButton( - onSlideComplete: onSlideComplete, - buttonText: 'Swipe to send', - currentTheme: currentTheme, - accessibleNavigationModeButtonText: S.of(context).send, - ), - ); - } } class StandardTile extends StatelessWidget { @@ -304,22 +311,28 @@ class AddressTile extends StatelessWidget { super.key, required this.itemTitle, required this.itemTitleTextStyle, - required this.isBatchSending, - required this.amount, required this.address, required this.itemSubTitleTextStyle, required this.tileBackgroundColor, required this.walletType, + this.amountTextStyle, + this.applyAddressFormatting = true, + this.imagePath, + this.amount, + this.itemSubTitle, }); final String itemTitle; final TextStyle itemTitleTextStyle; - final bool isBatchSending; - final String amount; + final String? amount; final String address; final TextStyle itemSubTitleTextStyle; + final TextStyle? amountTextStyle; final Color tileBackgroundColor; final WalletType walletType; + final bool applyAddressFormatting; + final String? imagePath; + final String? itemSubTitle; @override Widget build(BuildContext context) { @@ -335,26 +348,65 @@ class AddressTile extends StatelessWidget { Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Text(itemTitle, style: itemTitleTextStyle), - if (isBatchSending) Text(amount, style: itemTitleTextStyle), + Expanded( + child: Row( + children: [ + if (imagePath != null) + Padding( + padding: const EdgeInsets.only(right: 8), + child: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: ImageUtil.getImageFromPath( + imagePath: imagePath!, height: 40, width: 40), + ), + ), + Flexible( + child: Padding( + padding: const EdgeInsets.only(right: 8), + child: Text( + itemTitle, + style: itemTitleTextStyle, + overflow: TextOverflow.ellipsis, + maxLines: 1, + softWrap: false, + )), + ), + ], + ), + ), + if (amount != null) Text(amount!, style: amountTextStyle ?? itemTitleTextStyle), ], ), - AddressFormatter.buildSegmentedAddress( - address: address, - walletType: walletType, - evenTextStyle: Theme.of(context).textTheme.bodySmall!.copyWith( - fontWeight: FontWeight.w600, - decoration: TextDecoration.none, + address.isEmpty + ? Container() + : applyAddressFormatting + ? AddressFormatter.buildSegmentedAddress( + address: address, + walletType: walletType, + evenTextStyle: Theme.of(context).textTheme.bodySmall!.copyWith( + fontWeight: FontWeight.w600, + decoration: TextDecoration.none, + )) + : Text( + address, + style: itemTitleTextStyle, + ), + itemSubTitle == null + ? Container() + : Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Text(itemSubTitle!, style: itemSubTitleTextStyle), + ], ), - ), ], ), ); } } -class AddressExpansionTile extends StatelessWidget { - const AddressExpansionTile({ +class ExpansionAddressTile extends StatelessWidget { + const ExpansionAddressTile({ super.key, required this.contactType, required this.name, @@ -425,7 +477,6 @@ class AddressExpansionTile extends StatelessWidget { walletType: walletType, evenTextStyle: Theme.of(context).textTheme.bodySmall!.copyWith( fontWeight: FontWeight.w600, - decoration: TextDecoration.none, ), ), ), diff --git a/lib/src/widgets/bottom_sheet/info_bottom_sheet_widget.dart b/lib/src/widgets/bottom_sheet/info_bottom_sheet_widget.dart index 1c4cdff91..533949433 100644 --- a/lib/src/widgets/bottom_sheet/info_bottom_sheet_widget.dart +++ b/lib/src/widgets/bottom_sheet/info_bottom_sheet_widget.dart @@ -1,74 +1,96 @@ import 'package:auto_size_text/auto_size_text.dart'; +import 'package:cake_wallet/src/widgets/simple_checkbox.dart'; + + + +import 'package:cake_wallet/utils/image_utill.dart'; + +import 'package:cake_wallet/src/widgets/primary_button.dart'; +import 'package:cake_wallet/themes/core/material_base_theme.dart'; + + import 'package:cake_wallet/routes.dart'; import 'package:cake_wallet/src/widgets/primary_button.dart'; import 'package:cake_wallet/themes/core/material_base_theme.dart'; import 'package:flutter/gestures.dart'; + import 'package:flutter/material.dart'; -import 'package:flutter_svg/svg.dart'; import 'base_bottom_sheet_widget.dart'; class LoadingBottomSheet extends BaseBottomSheet { - LoadingBottomSheet({required String titleText, String? titleIconPath}) - : super(titleText: titleText, titleIconPath: titleIconPath); + LoadingBottomSheet( + {required String titleText, String? titleIconPath}) + : super(titleText: titleText, titleIconPath: titleIconPath, footerType: FooterType.none, maxHeight: 900); @override Widget contentWidget(BuildContext context) { return SizedBox( - height: 200, + height: 300, child: Center(child: CircularProgressIndicator()), ); } - - @override - Widget footerWidget(BuildContext context) => const SizedBox(height: 94); } class InfoBottomSheet extends BaseBottomSheet { - final MaterialThemeBase currentTheme; - final String? contentImage; - final Color? contentImageColor; - final String? content; - final bool isTwoAction; - final bool showDontAskMeCheckbox; - final bool showDisclaimerText; - final Function(bool)? onCheckboxChanged; - final String? actionButtonText; - final VoidCallback? actionButton; - final Key? actionButtonKey; - final String? leftButtonText; - final String? rightButtonText; - final VoidCallback? actionLeftButton; - final VoidCallback? actionRightButton; - final Key? rightActionButtonKey; - final Key? leftActionButtonKey; - final double height; - final double? contentImageSize; - InfoBottomSheet({ required String titleText, String? titleIconPath, required this.currentTheme, + required this.footerType, this.contentImage, this.contentImageColor, this.contentImageSize, - this.content, - this.isTwoAction = false, - this.showDontAskMeCheckbox = false, - this.showDisclaimerText = false, this.height = 200, - this.onCheckboxChanged, - this.actionButtonText, - this.actionButton, - this.actionButtonKey, - this.leftButtonText, - this.rightButtonText, - this.actionLeftButton, - this.actionRightButton, - this.rightActionButtonKey, + this.content, + this.bottomActionPanel, + this.singleActionButtonText, + this.onSingleActionButtonPressed, + this.singleActionButtonKey, + this.doubleActionLeftButtonText, + this.doubleActionRightButtonText, + this.onLeftActionButtonPressed, + this.onRightActionButtonPressed, this.leftActionButtonKey, - double maxHeight = 900, - }) : super(titleText: titleText, titleIconPath: titleIconPath, maxHeight: maxHeight); + this.rightActionButtonKey, + this.showDisclaimerText = true, + Key? key, + }) : super( + titleText: titleText, + titleIconPath: titleIconPath, + maxHeight: 900, + currentTheme: currentTheme, + footerType: footerType, + singleActionButtonText: singleActionButtonText, + onSingleActionButtonPressed: onSingleActionButtonPressed, + singleActionButtonKey: singleActionButtonKey, + doubleActionLeftButtonText: doubleActionLeftButtonText, + doubleActionRightButtonText: doubleActionRightButtonText, + onLeftActionButtonPressed: onLeftActionButtonPressed, + onRightActionButtonPressed: onRightActionButtonPressed, + leftActionButtonKey: leftActionButtonKey, + rightActionButtonKey: rightActionButtonKey, + key: key); + + final MaterialThemeBase currentTheme; + final FooterType footerType; + final String? contentImage; + final Color? contentImageColor; + final String? content; + final Widget? bottomActionPanel; + final String? singleActionButtonText; + final VoidCallback? onSingleActionButtonPressed; + final Key? singleActionButtonKey; + final String? doubleActionLeftButtonText; + final String? doubleActionRightButtonText; + final VoidCallback? onLeftActionButtonPressed; + final VoidCallback? onRightActionButtonPressed; + final Key? rightActionButtonKey; + final Key? leftActionButtonKey; + final double height; + final double? contentImageSize; + final bool showDisclaimerText; + @override Widget contentWidget(BuildContext context) { @@ -79,9 +101,11 @@ class InfoBottomSheet extends BaseBottomSheet { if (contentImage != null) Expanded( flex: 4, - child: SizedBox( - width: contentImageSize, - child: getImage(contentImage!, imageColor: contentImageColor), + child: ImageUtil.getImageFromPath( + imagePath: contentImage!, + svgImageColor: contentImageColor, + fit: BoxFit.contain, + borderRadius: 10, ), ) else @@ -110,6 +134,7 @@ class InfoBottomSheet extends BaseBottomSheet { ], ), ), + bottomActionPanel ?? const SizedBox(), if (showDisclaimerText) Padding( padding: const EdgeInsets.only(top: 20, bottom: 10), @@ -141,123 +166,8 @@ class InfoBottomSheet extends BaseBottomSheet { ), ), ), - if (showDontAskMeCheckbox) - Padding( - padding: const EdgeInsets.only(left: 34), - child: Row( - children: [ - SimpleCheckbox(onChanged: onCheckboxChanged), - const SizedBox(width: 8), - Text( - 'Don’t ask me next time', - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.bodyMedium!.copyWith( - fontWeight: FontWeight.w500, - color: Theme.of(context).colorScheme.onSurfaceVariant, - decoration: TextDecoration.none, - ), - ), - ], - ), - ), ], ), ); } - - @override - Widget footerWidget(BuildContext context) { - if (isTwoAction) { - return Padding( - padding: const EdgeInsets.fromLTRB(16, 0, 16, 34), - child: Row( - mainAxisSize: MainAxisSize.max, - children: [ - Flexible( - child: Container( - padding: const EdgeInsets.only(right: 8.0, top: 8.0), - child: PrimaryButton( - key: leftActionButtonKey, - onPressed: actionLeftButton, - text: leftButtonText ?? '', - color: Theme.of(context).colorScheme.surfaceContainer, - textColor: Theme.of(context).colorScheme.onSecondaryContainer, - ), - ), - ), - Flexible( - child: Container( - padding: const EdgeInsets.only(left: 8.0, top: 8.0), - child: PrimaryButton( - key: rightActionButtonKey, - onPressed: actionRightButton, - text: rightButtonText ?? '', - color: Theme.of(context).colorScheme.primary, - textColor: Theme.of(context).colorScheme.onPrimary, - ), - ), - ), - ], - ), - ); - } else { - return Padding( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 34), - child: LoadingPrimaryButton( - key: actionButtonKey, - onPressed: actionButton ?? () {}, - text: actionButtonText ?? '', - color: Theme.of(context).colorScheme.primary, - textColor: Theme.of(context).colorScheme.onPrimary, - isLoading: false, - isDisabled: false, - ), - ); - } - } - - Widget getImage(String imagePath, {Color? imageColor}) { - final bool isSvg = imagePath.endsWith('.svg'); - if (isSvg) { - return SvgPicture.asset( - imagePath, - colorFilter: imageColor != null ? ColorFilter.mode(imageColor, BlendMode.srcIn) : null, - ); - } else { - return Image.asset(imagePath); - } - } -} - -class SimpleCheckbox extends StatefulWidget { - SimpleCheckbox({this.onChanged}); - - final Function(bool)? onChanged; - - @override - State createState() => _SimpleCheckboxState(); -} - -class _SimpleCheckboxState extends State { - bool initialValue = false; - - @override - Widget build(BuildContext context) { - return SizedBox( - height: 24.0, - width: 24.0, - child: Checkbox( - value: initialValue, - onChanged: (value) => setState(() { - initialValue = value!; - widget.onChanged?.call(value); - }), - checkColor: Theme.of(context).colorScheme.onSurfaceVariant, - activeColor: Colors.transparent, - materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, - side: WidgetStateBorderSide.resolveWith((states) => - BorderSide(color: Theme.of(context).colorScheme.onSurfaceVariant, width: 1.0)), - ), - ); - } } diff --git a/lib/src/widgets/bottom_sheet/info_steps_bottom_sheet_widget.dart b/lib/src/widgets/bottom_sheet/info_steps_bottom_sheet_widget.dart index 8b78b5b37..327b2d543 100644 --- a/lib/src/widgets/bottom_sheet/info_steps_bottom_sheet_widget.dart +++ b/lib/src/widgets/bottom_sheet/info_steps_bottom_sheet_widget.dart @@ -21,7 +21,7 @@ class InfoStepsBottomSheet extends BaseBottomSheet { required this.steps, String? titleIconPath, required this.currentTheme, - }) : super(titleText: titleText, titleIconPath: titleIconPath); + }) : super(titleText: titleText, titleIconPath: titleIconPath, footerType: FooterType.none, maxHeight: 900); @override Widget contentWidget(BuildContext context) => SizedBox( diff --git a/lib/src/widgets/number_text_fild_widget.dart b/lib/src/widgets/number_text_fild_widget.dart index 635b86f41..2a3d371cd 100644 --- a/lib/src/widgets/number_text_fild_widget.dart +++ b/lib/src/widgets/number_text_fild_widget.dart @@ -56,67 +56,47 @@ class _NumberTextFieldState extends State { @override Widget build(BuildContext context) => TextField( - style: textMediumSemiBold(color: Theme.of(context).colorScheme.onSurfaceVariant), - enableInteractiveSelection: false, - textAlign: TextAlign.center, - textAlignVertical: TextAlignVertical.bottom, - controller: _controller, - focusNode: _focusNode, - textInputAction: TextInputAction.done, - keyboardType: TextInputType.number, - maxLength: widget.max.toString().length + (widget.min.isNegative ? 1 : 0), - decoration: InputDecoration( + style: Theme.of(context).textTheme.titleMedium!, + enableInteractiveSelection: false, + textAlign: TextAlign.center, + textAlignVertical: TextAlignVertical.bottom, + controller: _controller, + focusNode: _focusNode, + textInputAction: TextInputAction.done, + keyboardType: TextInputType.number, + maxLength: widget.max.toString().length + (widget.min.isNegative ? 1 : 0), + decoration: InputDecoration( border: InputBorder.none, contentPadding: EdgeInsets.all(0), fillColor: Colors.transparent, counterText: '', isDense: true, filled: true, - suffixIconConstraints: BoxConstraints( - maxHeight: widget.arrowsHeight, - maxWidth: widget.arrowsWidth + widget.contentPadding.right, - ), - prefixIconConstraints: BoxConstraints( - maxHeight: widget.arrowsHeight, - maxWidth: widget.arrowsWidth + widget.contentPadding.left, - ), + suffixIconConstraints: BoxConstraints(minWidth: 0, minHeight: 0), + prefixIconConstraints: BoxConstraints(minWidth: 0, minHeight: 0), prefixIcon: Material( - type: MaterialType.transparency, - child: InkWell( - child: Container( - width: widget.arrowsWidth, - alignment: Alignment.bottomCenter, - child: Icon( - Icons.arrow_left_outlined, - size: widget.arrowsWidth, - ), - ), - onTap: _canGoDown ? () => _update(false) : null, - ), - ), + type: MaterialType.transparency, + child: InkWell( + child: Container( + width: widget.arrowsWidth, + alignment: Alignment.bottomCenter, + child: Icon(Icons.keyboard_arrow_left_outlined ,size: widget.arrowsWidth)), + onTap: _canGoDown ? () => _update(false) : null)), suffixIcon: Material( - type: MaterialType.transparency, - child: InkWell( - child: Container( - width: widget.arrowsWidth, - alignment: Alignment.bottomCenter, - child: Icon( - Icons.arrow_right_outlined, - size: widget.arrowsWidth, - ), - ), - onTap: _canGoUp ? () => _update(true) : null, - ), - ), - ), - maxLines: 1, - onChanged: (value) { - final intValue = int.tryParse(value); - widget.onChanged?.call(intValue); - _updateArrows(intValue); - }, - inputFormatters: [_NumberTextInputFormatter(widget.min, widget.max)], - ); + type: MaterialType.transparency, + child: InkWell( + child: Container( + width: widget.arrowsWidth, + alignment: Alignment.bottomCenter, + child: Icon(Icons.keyboard_arrow_right_outlined, size: widget.arrowsWidth)), + onTap: _canGoUp ? () => _update(true) : null))), + maxLines: 1, + onChanged: (value) { + final intValue = int.tryParse(value); + widget.onChanged?.call(intValue); + _updateArrows(intValue); + }, + inputFormatters: [_NumberTextInputFormatter(widget.min, widget.max)]); void _update(bool up) { var intValue = int.tryParse(_controller.text); diff --git a/lib/src/screens/cake_pay/widgets/rounded_checkbox.dart b/lib/src/widgets/rounded_checkbox.dart similarity index 100% rename from lib/src/screens/cake_pay/widgets/rounded_checkbox.dart rename to lib/src/widgets/rounded_checkbox.dart diff --git a/lib/src/widgets/simple_checkbox.dart b/lib/src/widgets/simple_checkbox.dart new file mode 100644 index 000000000..850d9bac1 --- /dev/null +++ b/lib/src/widgets/simple_checkbox.dart @@ -0,0 +1,34 @@ +import 'package:flutter/material.dart'; + +class SimpleCheckbox extends StatefulWidget { + SimpleCheckbox({this.onChanged}); + + final Function(bool)? onChanged; + + @override + State createState() => _SimpleCheckboxState(); +} + +class _SimpleCheckboxState extends State { + bool initialValue = false; + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 24.0, + width: 24.0, + child: Checkbox( + value: initialValue, + onChanged: (value) => setState(() { + initialValue = value!; + widget.onChanged?.call(value); + }), + checkColor: Theme.of(context).textTheme.titleLarge!.color, + activeColor: Colors.transparent, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + side: WidgetStateBorderSide.resolveWith((states) => BorderSide( + color: Theme.of(context).textTheme.titleLarge!.color!, width: 1.0)), + ), + ); + } +} \ No newline at end of file diff --git a/lib/src/widgets/standard_slide_button_widget.dart b/lib/src/widgets/standard_slide_button_widget.dart index a0ef4eb2f..4fbd25506 100644 --- a/lib/src/widgets/standard_slide_button_widget.dart +++ b/lib/src/widgets/standard_slide_button_widget.dart @@ -1,5 +1,6 @@ import 'package:cake_wallet/src/widgets/primary_button.dart'; import 'package:cake_wallet/themes/core/material_base_theme.dart'; +import 'package:cake_wallet/themes/utils/custom_theme_colors.dart'; import 'package:flutter/material.dart'; class StandardSlideButton extends StatefulWidget { @@ -29,7 +30,9 @@ class _StandardSlideButtonState extends State { Widget build(BuildContext context) { final bool accessible = MediaQuery.of(context).accessibleNavigation; - final tileBackgroundColor = Theme.of(context).colorScheme.surfaceContainer; + final tileBackgroundColor = widget.currentTheme.isDark + ? CustomThemeColors.backgroundGradientColorDark + : CustomThemeColors.backgroundGradientColorLight; return accessible ? PrimaryButton( @@ -87,7 +90,7 @@ class _StandardSlideButtonState extends State { height: widget.height - 8, decoration: BoxDecoration( borderRadius: BorderRadius.circular(10), - color: Theme.of(context).colorScheme.primary, + color: Theme.of(context).colorScheme.surface, ), alignment: Alignment.center, child: Icon( diff --git a/lib/src/widgets/tab_view_wrapper_widget.dart b/lib/src/widgets/tab_view_wrapper_widget.dart new file mode 100644 index 000000000..4fbf2a31d --- /dev/null +++ b/lib/src/widgets/tab_view_wrapper_widget.dart @@ -0,0 +1,79 @@ +import 'package:flutter/material.dart'; + +class TabViewWrapper extends StatefulWidget { + const TabViewWrapper({ + super.key, + required this.tabs, + required this.views, + this.tabBarPadding = const EdgeInsets.only(right: 24), + this.labelStyle, + this.unselectedLabelStyle, + this.indicatorColor, + }) : assert(tabs.length == views.length, 'Tabs and views must be of equal length.'); + + final List tabs; + final List views; + final EdgeInsets tabBarPadding; + final TextStyle? labelStyle; + final TextStyle? unselectedLabelStyle; + final Color? indicatorColor; + + @override + State createState() => _TabViewWrapperState(); +} + +class _TabViewWrapperState extends State with SingleTickerProviderStateMixin { + late final TabController _tabController; + + @override + void initState() { + super.initState(); + _tabController = TabController(length: widget.tabs.length, vsync: this); + } + + @override + void dispose() { + _tabController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final textStyle = TextStyle( + fontSize: 18, + fontFamily: 'Lato', + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.onSurface); + + return Column( + children: [ + Align( + alignment: Alignment.centerLeft, + child: TabBar( + controller: _tabController, + isScrollable: true, + splashFactory: NoSplash.splashFactory, + indicatorSize: TabBarIndicatorSize.label, + labelStyle: widget.labelStyle ?? textStyle, + unselectedLabelStyle: widget.unselectedLabelStyle ?? + textStyle.copyWith(color: textStyle.color?.withAlpha(150)), + labelColor: widget.labelStyle?.color ?? textStyle.color, + indicatorColor: widget.indicatorColor, + indicatorPadding: EdgeInsets.zero, + labelPadding: widget.tabBarPadding, + tabAlignment: TabAlignment.start, + dividerColor: Colors.transparent, + padding: EdgeInsets.zero, + tabs: widget.tabs, + ), + ), + Expanded( + child: TabBarView( + controller: _tabController, + children: widget.views, + ), + ), + ], + ); + } +} diff --git a/lib/utils/feature_flag.dart b/lib/utils/feature_flag.dart index b9314bc6e..c1fbeefc0 100644 --- a/lib/utils/feature_flag.dart +++ b/lib/utils/feature_flag.dart @@ -3,6 +3,7 @@ import 'dart:io'; class FeatureFlag { static const bool isCakePayEnabled = false; + static const bool isCakePayRedemptionFlowEnabled = false; static const bool isExolixEnabled = true; static const bool isBackgroundSyncEnabled = true; static final bool isInAppTorEnabled = (Platform.isAndroid); diff --git a/lib/utils/image_utill.dart b/lib/utils/image_utill.dart index 40dde7020..29cee5e0f 100644 --- a/lib/utils/image_utill.dart +++ b/lib/utils/image_utill.dart @@ -3,89 +3,81 @@ import 'package:flutter/material.dart'; import 'package:flutter_svg/svg.dart'; class ImageUtil { - static Widget getImageFromPath({required String imagePath, double? height, double? width}) { + static Widget getImageFromPath({ + required String imagePath, + double? height, + double? width, + Color? svgImageColor, + BoxFit? fit, + double? borderRadius, + }) { bool isNetworkImage = imagePath.startsWith('http') || imagePath.startsWith('https'); + if (CakeTor.instance.enabled && isNetworkImage) { imagePath = "assets/images/tor_logo.svg"; isNetworkImage = false; } - final bool isSvg = imagePath.endsWith('.svg'); - final double _height = height ?? 35; - final double _width = width ?? 35; + final isSvg = imagePath.endsWith('.svg'); + final bool ignoreSize = fit != null; + final double? _height = ignoreSize ? null : (height ?? 35); + final double? _width = ignoreSize ? null : (width ?? 35); + + Widget img; if (isNetworkImage) { - return isSvg - ? SvgPicture.network( + img = isSvg + ? SvgPicture.network(imagePath, key: ValueKey(imagePath), - imagePath, height: _height, width: _width, - placeholderBuilder: (BuildContext context) => Container( - height: _height, - width: _width, - child: Center( - child: CircularProgressIndicator(), - ), - ), - errorBuilder: (_, __, ___) { - return Container( - height: _height, - width: _width, - child: Center( - child: Icon(Icons.error_outline, color: Colors.grey), - ), - ); - }, - ) - : Image.network( + fit: fit ?? BoxFit.contain, + placeholderBuilder: (_) => _placeholder(_height, _width), + errorBuilder: (_, __, ___) => _errorPlaceholder(_height, _width)) + : Image.network(imagePath, key: ValueKey(imagePath), - imagePath, height: _height, width: _width, - loadingBuilder: - (BuildContext context, Widget child, ImageChunkEvent? loadingProgress) { - if (loadingProgress == null) { - return child; - } - return Container( - height: _height, - width: _width, - child: Center( - child: CircularProgressIndicator( - value: loadingProgress.expectedTotalBytes != null - ? loadingProgress.cumulativeBytesLoaded / - loadingProgress.expectedTotalBytes! - : null, - ), - ), - ); - }, - errorBuilder: (BuildContext context, Object exception, StackTrace? stackTrace) { - return Container( - height: _height, - width: _width, - child: Center( - child: Icon(Icons.error_outline, color: Colors.grey), - ), - ); - }, - ); + fit: fit, + loadingBuilder: (_, child, progress) => + progress == null ? child : _placeholder(_height, _width), + errorBuilder: (_, __, ___) => _errorPlaceholder(_height, _width)); } else { - return isSvg - ? SvgPicture.asset( - imagePath, + img = isSvg + ? SvgPicture.asset(imagePath, + key: ValueKey(imagePath), height: _height, width: _width, - placeholderBuilder: (_) => Icon(Icons.error), - errorBuilder: (_, __, ___) => Icon(Icons.error), - key: ValueKey(imagePath), - ) + fit: fit ?? BoxFit.contain, + colorFilter: + svgImageColor != null ? ColorFilter.mode(svgImageColor, BlendMode.srcIn) : null, + placeholderBuilder: (_) => _placeholder(_height, _width), + errorBuilder: (_, __, ___) => _errorPlaceholder(_height, _width)) : Image.asset( imagePath, + key: ValueKey(imagePath), height: _height, width: _width, - errorBuilder: (_, __, ___) => Icon(Icons.error), - key: ValueKey(imagePath), + fit: fit, + errorBuilder: (_, __, ___) => _errorPlaceholder(_height, _width), ); } + + if (borderRadius != null && borderRadius > 0) { + img = ClipRRect( + borderRadius: BorderRadius.circular(borderRadius), + child: img, + ); + } + return img; } + + static Widget _placeholder(double? h, double? w) => (h != null || w != null) + ? SizedBox(height: h, width: w, child: const Center(child: CircularProgressIndicator())) + : const Center(child: CircularProgressIndicator()); + + static Widget _errorPlaceholder(double? h, double? w) => (h != null || w != null) + ? SizedBox( + height: h, + width: w, + child: const Center(child: Icon(Icons.error_outline, color: Colors.grey))) + : const Center(child: Icon(Icons.error_outline, color: Colors.grey)); } diff --git a/lib/view_model/cake_pay/cake_pay_account_view_model.dart b/lib/view_model/cake_pay/cake_pay_account_view_model.dart index 85c68c9fb..35727f451 100644 --- a/lib/view_model/cake_pay/cake_pay_account_view_model.dart +++ b/lib/view_model/cake_pay/cake_pay_account_view_model.dart @@ -1,4 +1,4 @@ -import 'package:cake_wallet/cake_pay/cake_pay_service.dart'; +import 'package:cake_wallet/cake_pay/src/services/cake_pay_service.dart'; import 'package:mobx/mobx.dart'; part 'cake_pay_account_view_model.g.dart'; diff --git a/lib/view_model/cake_pay/cake_pay_auth_view_model.dart b/lib/view_model/cake_pay/cake_pay_auth_view_model.dart index f23d43f1f..dfc22ca39 100644 --- a/lib/view_model/cake_pay/cake_pay_auth_view_model.dart +++ b/lib/view_model/cake_pay/cake_pay_auth_view_model.dart @@ -1,5 +1,5 @@ -import 'package:cake_wallet/cake_pay/cake_pay_states.dart'; -import 'package:cake_wallet/cake_pay/cake_pay_service.dart'; +import 'package:cake_wallet/cake_pay/src/cake_pay_states.dart'; +import 'package:cake_wallet/cake_pay/src/services/cake_pay_service.dart'; import 'package:mobx/mobx.dart'; part 'cake_pay_auth_view_model.g.dart'; diff --git a/lib/view_model/cake_pay/cake_pay_buy_card_view_model.dart b/lib/view_model/cake_pay/cake_pay_buy_card_view_model.dart index 4fd97213d..0540b93db 100644 --- a/lib/view_model/cake_pay/cake_pay_buy_card_view_model.dart +++ b/lib/view_model/cake_pay/cake_pay_buy_card_view_model.dart @@ -1,5 +1,14 @@ -import 'package:cake_wallet/cake_pay/cake_pay_card.dart'; -import 'package:cake_wallet/cake_pay/cake_pay_vendor.dart'; +import 'dart:async'; + +import 'package:cake_wallet/cake_pay/src/models/cake_pay_card.dart'; +import 'package:cake_wallet/cake_pay/src/models/cake_pay_order.dart'; +import 'package:cake_wallet/cake_pay/src/models/cake_pay_vendor.dart'; +import 'package:cake_wallet/cake_pay/src/services/cake_pay_service.dart'; +import 'package:cake_wallet/core/execution_state.dart'; +import 'package:cake_wallet/utils/feature_flag.dart'; +import 'package:cake_wallet/view_model/send/send_view_model.dart'; +import 'package:cake_wallet/view_model/send/send_view_model_state.dart'; +import 'package:cw_core/wallet_type.dart'; import 'package:mobx/mobx.dart'; part 'cake_pay_buy_card_view_model.g.dart'; @@ -7,20 +16,36 @@ part 'cake_pay_buy_card_view_model.g.dart'; class CakePayBuyCardViewModel = CakePayBuyCardViewModelBase with _$CakePayBuyCardViewModel; abstract class CakePayBuyCardViewModelBase with Store { - CakePayBuyCardViewModelBase({required this.vendor}) - : amount = vendor.card!.denominations.isNotEmpty + CakePayBuyCardViewModelBase( + {required this.vendor, required this.cakePayService, required this.sendViewModel}) + : walletType = sendViewModel.walletType, + amount = vendor.card!.denominations.isNotEmpty ? double.parse(vendor.card!.denominations.first) : 0, quantity = 1, min = double.parse(vendor.card!.minValue ?? '0'), max = double.parse(vendor.card!.maxValue ?? '0'), - card = vendor.card!; + card = vendor.card! { + selectedPaymentMethod = availableMethods.isNotEmpty ? availableMethods.first : null; + } final CakePayVendor vendor; - final CakePayCard card; - - final double min; + final SendViewModel sendViewModel; + final CakePayService cakePayService; final double max; + final double min; + final CakePayCard card; + final WalletType walletType; + + CakePayOrder? order; + Timer? _timer; + DateTime? expirationTime; + Duration? remainingTime; + bool confirmsNoVpn = false; + bool confirmsVoidedRefund = false; + bool confirmsTermsAgreed = false; + + String simulatedResponse = ''; bool get isDenominationSelected => card.denominations.isNotEmpty; @@ -30,13 +55,48 @@ abstract class CakePayBuyCardViewModelBase with Store { @observable int quantity; + @observable + bool isPurchasing = false; + + @observable + bool isSimulatingFlow = false; + + @observable + bool isOrderExpired = false; + + @observable + String formattedRemainingTime = ''; + @computed - bool get isEnablePurchase => + bool get isAmountSufficient => (amount >= min && amount <= max) || (isDenominationSelected && quantity > 0); + @observable + CakePayPaymentMethod? selectedPaymentMethod; + @computed double get totalAmount => amount * quantity; + @computed + bool get isSimulating => isSimulatingFlow && FeatureFlag.hasDevOptions; + + @computed + List get availableMethods { + switch (walletType) { + case WalletType.bitcoin: + return [CakePayPaymentMethod.BTC]; + case WalletType.litecoin: + return [CakePayPaymentMethod.LTC, CakePayPaymentMethod.LTC_MWEB]; + case WalletType.monero: + return [CakePayPaymentMethod.XMR]; + default: + return const []; + } + } + + @action + void chooseMethod(CakePayPaymentMethod method) => selectedPaymentMethod = method; + @action void onQuantityChanged(int? input) => quantity = input ?? 1; @@ -45,4 +105,135 @@ abstract class CakePayBuyCardViewModelBase with Store { if (input.isEmpty) return; amount = double.parse(input.replaceAll(',', '.')); } + + CryptoPaymentData? getPaymentDataFor(CakePayPaymentMethod? method) { + if (order == null || method == null) return null; + + + final data = switch (method) { + CakePayPaymentMethod.BTC => order?.paymentData.btc, + CakePayPaymentMethod.XMR => order?.paymentData.xmr, + CakePayPaymentMethod.LTC => order?.paymentData.ltc, + CakePayPaymentMethod.LTC_MWEB => order?.paymentData.ltc_mweb, + _ => null + }; + + if (data == null) return null; + + final bip21 = data.paymentUrls?.bip21; + if (bip21 != null && bip21.isNotEmpty) { + final uri = Uri.parse(bip21); + final addr = uri.path; + final price = uri.queryParameters['amount'] ?? data.price; + + return CryptoPaymentData(price: price, address: addr); + } + + return data; + } + + @action + Future createOrder() async { + if (walletType != WalletType.bitcoin && + walletType != WalletType.monero && + walletType != WalletType.litecoin) { + sendViewModel.state = + FailureState('Unsupported wallet type, please use Bitcoin, Monero, or Litecoin.'); + } + try { + order = await cakePayService.createOrder( + cardId: card.id, + price: amount.toString(), + quantity: quantity, + confirmsNoVpn: confirmsNoVpn, + confirmsVoidedRefund: confirmsVoidedRefund, + confirmsTermsAgreed: confirmsTermsAgreed, + ); + await confirmSending(); + expirationTime = order!.paymentData.expirationTime; + updateRemainingTime(); + _startExpirationTimer(); + } catch (e) { + sendViewModel.state = FailureState( + sendViewModel.translateErrorMessage(e, walletType, sendViewModel.wallet.currency)); + } + } + + @action + Future confirmSending() async { + final cryptoPaymentData = getPaymentDataFor(selectedPaymentMethod); + if (order == null || cryptoPaymentData == null) return; + + try { + sendViewModel.clearOutputs(); + final output = sendViewModel.outputs.first; + output.address = cryptoPaymentData.address; + output.setCryptoAmount(cryptoPaymentData.price); + + await sendViewModel.createTransaction(); + } catch (e) { + throw e; + } + } + + @action + Future simulatePayment() async { + if (order == null) { + throw Exception('Order is not created yet.'); + } + + try { + simulatedResponse = await cakePayService.simulatePayment(orderId: order!.orderId); + sendViewModel.state = TransactionCommitted(); + + } catch (e) { + sendViewModel.state = FailureState( + sendViewModel.translateErrorMessage(e, walletType, sendViewModel.wallet.currency)); + } + } + + @action + void updateRemainingTime() { + if (expirationTime == null) { + formattedRemainingTime = ''; + return; + } + + remainingTime = expirationTime!.difference(DateTime.now()); + + isOrderExpired = remainingTime!.isNegative; + + if (isOrderExpired) { + disposeExpirationTimer(); + sendViewModel.state = FailureState('Order has expired.'); + } else { + formattedRemainingTime = formatDuration(remainingTime!); + } + } + + void _startExpirationTimer() { + _timer?.cancel(); + _timer = Timer.periodic(Duration(seconds: 1), (_) { + updateRemainingTime(); + }); + } + + String formatDuration(Duration duration) { + final hours = duration.inHours; + final minutes = duration.inMinutes.remainder(60); + final seconds = duration.inSeconds.remainder(60); + return '${hours.toString().padLeft(2, '0')}:${minutes.toString().padLeft(2, '0')}:${seconds + .toString().padLeft(2, '0')}'; + } + + void disposeExpirationTimer() { + _timer?.cancel(); + remainingTime = null; + formattedRemainingTime = ''; + expirationTime = null; + } + + void dispose() { + disposeExpirationTimer(); + } } diff --git a/lib/view_model/cake_pay/cake_pay_cards_list_view_model.dart b/lib/view_model/cake_pay/cake_pay_cards_list_view_model.dart index e777813c7..cbcfca858 100644 --- a/lib/view_model/cake_pay/cake_pay_cards_list_view_model.dart +++ b/lib/view_model/cake_pay/cake_pay_cards_list_view_model.dart @@ -1,6 +1,9 @@ -import 'package:cake_wallet/cake_pay/cake_pay_service.dart'; -import 'package:cake_wallet/cake_pay/cake_pay_states.dart'; -import 'package:cake_wallet/cake_pay/cake_pay_vendor.dart'; +import 'dart:async'; + +import 'package:cake_wallet/cake_pay/src/models/cake_pay_card.dart'; +import 'package:cake_wallet/cake_pay/src/services/cake_pay_service.dart'; +import 'package:cake_wallet/cake_pay/src/cake_pay_states.dart'; +import 'package:cake_wallet/cake_pay/src/models/cake_pay_vendor.dart'; import 'package:cake_wallet/entities/country.dart'; import 'package:cake_wallet/entities/fiat_currency.dart'; import 'package:cake_wallet/generated/i18n.dart'; @@ -17,8 +20,8 @@ abstract class CakePayCardsListViewModelBase with Store { CakePayCardsListViewModelBase({ required this.cakePayService, required this.settingsStore, - }) : cardState = CakePayCardsStateNoCards(), - cakePayVendors = [], + }) : cakePayVendors = [], + userCards = [], availableCountries = [], page = 1, displayPrepaidCards = true, @@ -28,8 +31,11 @@ abstract class CakePayCardsListViewModelBase with Store { scrollOffsetFromTop = 0.0, vendorsState = InitialCakePayVendorLoadingState(), createCardState = CakePayCreateCardState(), + userCardState = UserCakePayCardsStateInitial(), searchString = '', + searchMyCardsString = '', CakePayVendorList = [] { + checkAuth(); initialization(); } @@ -43,6 +49,7 @@ abstract class CakePayCardsListViewModelBase with Store { void initialization() async { await getCountries(); getVendors(); + getUserCards(); } final CakePayService cakePayService; @@ -51,7 +58,7 @@ abstract class CakePayCardsListViewModelBase with Store { List CakePayVendorList; Map> get createFilterItems => { - S.current.filter_by: [ + 'Card Type': [ FilterItem( value: () => displayPrepaidCards, caption: S.current.prepaid_cards, @@ -74,6 +81,7 @@ abstract class CakePayCardsListViewModelBase with Store { }; String searchString; + String? username; int page; late Country _initialSelectedCountry; @@ -82,6 +90,12 @@ abstract class CakePayCardsListViewModelBase with Store { late bool _initialDisplayDenominationsCards; late bool _initialDisplayCustomValueCards; + @observable + List userCards; + + @observable + String searchMyCardsString; + @observable double scrollOffsetFromTop; @@ -89,7 +103,7 @@ abstract class CakePayCardsListViewModelBase with Store { CakePayCreateCardState createCardState; @observable - CakePayCardsState cardState; + UserCakePayCardsState userCardState; @observable CakePayVendorState vendorsState; @@ -118,12 +132,29 @@ abstract class CakePayCardsListViewModelBase with Store { @observable bool displayCustomValueCards; + @observable + ObservableFuture? authFuture; + + @computed + bool? get isUserAuthenticated => + authFuture?.status == FutureStatus.fulfilled ? authFuture?.value : null; + @computed Country get selectedCountry => settingsStore.selectedCakePayCountry ?? _getInitialCountry(settingsStore.fiatCurrency); @computed - bool get shouldShowCountryPicker => settingsStore.selectedCakePayCountry == null && availableCountries.isNotEmpty; + bool get shouldShowCountryPicker => + settingsStore.selectedCakePayCountry == null && availableCountries.isNotEmpty; + + @computed + List get filteredUserCards { + final query = searchMyCardsString.trim().toLowerCase(); + if (query.isEmpty) return userCards; + return userCards + .where((card) => card.name.toLowerCase().contains(query)) + .toList(growable: false); + } bool get hasFiltersChanged { @@ -134,7 +165,6 @@ abstract class CakePayCardsListViewModelBase with Store { displayCustomValueCards != _initialDisplayCustomValueCards; } - Future getCountries() async { try { availableCountries = await cakePayService.getCountries(); @@ -143,6 +173,44 @@ abstract class CakePayCardsListViewModelBase with Store { } } + Future getUserCards() async { + //Dummy user cards // TODO: fetch from API + userCardState = UserCakePayCardsStateFetching(); + try { + await Future.delayed(const Duration(seconds: 2)); + final vendorsCard = cakePayVendors + .where((vendor) => vendor.card != null) + .map((vendor) => vendor.card!) + .toList(); + userCards = vendorsCard.sublist(0, 10); + + vendorsCard.forEach((card) { + if (card.name.toLowerCase().contains('amazon.com')) { + userCards.add(card); + } + }); + + userCardState = UserCakePayCardsStateSuccess(); + if (userCards.isEmpty) { + userCardState = UserCakePayCardsStateNoCards(); + } + } catch (e) { + userCardState = UserCakePayCardsStateFailure( + error: e.toString(), + ); + } + } + + @action + Future checkAuth() async { + authFuture = ObservableFuture(cakePayService.isLogged()); + + final logged = await authFuture!; + if (logged) { + username = await cakePayService.getUserEmail(); + } + } + @action Future getVendors({ String? text, @@ -219,6 +287,9 @@ abstract class CakePayCardsListViewModelBase with Store { settingsStore.selectedCakePayCountry = country; } + @action + void setMyCardsQuery(String text) => searchMyCardsString = text; + @action void togglePrepaidCards() => displayPrepaidCards = !displayPrepaidCards; diff --git a/lib/view_model/cake_pay/cake_pay_purchase_view_model.dart b/lib/view_model/cake_pay/cake_pay_purchase_view_model.dart deleted file mode 100644 index f95f9e2b7..000000000 --- a/lib/view_model/cake_pay/cake_pay_purchase_view_model.dart +++ /dev/null @@ -1,173 +0,0 @@ -import 'dart:async'; - -import 'package:cake_wallet/cake_pay/cake_pay_card.dart'; -import 'package:cake_wallet/cake_pay/cake_pay_order.dart'; -import 'package:cake_wallet/cake_pay/cake_pay_payment_credantials.dart'; -import 'package:cake_wallet/cake_pay/cake_pay_service.dart'; -import 'package:cake_wallet/core/execution_state.dart'; -import 'package:cake_wallet/view_model/send/send_view_model.dart'; -import 'package:cw_core/wallet_type.dart'; -import 'package:mobx/mobx.dart'; - -part 'cake_pay_purchase_view_model.g.dart'; - -class CakePayPurchaseViewModel = CakePayPurchaseViewModelBase with _$CakePayPurchaseViewModel; - -abstract class CakePayPurchaseViewModelBase with Store { - CakePayPurchaseViewModelBase({ - required this.cakePayService, - required this.paymentCredential, - required this.card, - required this.sendViewModel, - }) : walletType = sendViewModel.walletType; - - final WalletType walletType; - - final PaymentCredential paymentCredential; - - final CakePayCard card; - - final SendViewModel sendViewModel; - - final CakePayService cakePayService; - - CakePayOrder? order; - - Timer? _timer; - - DateTime? expirationTime; - - Duration? remainingTime; - - String? get userName => paymentCredential.userName; - - double get amount => paymentCredential.amount; - - int get quantity => paymentCredential.quantity; - - double get totalAmount => paymentCredential.totalAmount; - - String get fiatCurrency => paymentCredential.fiatCurrency; - - bool confirmsNoVpn = false; - bool confirmsVoidedRefund = false; - bool confirmsTermsAgreed = false; - - @observable - bool isPurchasing = false; - - CryptoPaymentData? get cryptoPaymentData { - if (order == null) return null; - - if (WalletType.monero == walletType) { - return order!.paymentData.xmr; - } - - if (WalletType.bitcoin == walletType) { - final paymentUrls = order!.paymentData.btc.paymentUrls!.bip21; - - final uri = Uri.parse(paymentUrls!); - - final address = uri.path; - final price = uri.queryParameters['amount']; - - return CryptoPaymentData( - address: address, - price: price ?? '0', - ); - } - - return null; - } - - @observable - bool isOrderExpired = false; - - @observable - String formattedRemainingTime = ''; - - @action - Future createOrder() async { - if (walletType != WalletType.bitcoin && walletType != WalletType.monero) { - sendViewModel.state = FailureState('Unsupported wallet type, please use Bitcoin or Monero.'); - } - try { - order = await cakePayService.createOrder( - cardId: card.id, - price: paymentCredential.amount.toString(), - quantity: paymentCredential.quantity, - confirmsNoVpn: confirmsNoVpn, - confirmsVoidedRefund: confirmsVoidedRefund, - confirmsTermsAgreed: confirmsTermsAgreed, - ); - await confirmSending(); - expirationTime = order!.paymentData.expirationTime; - updateRemainingTime(); - _startExpirationTimer(); - } catch (e) { - sendViewModel.state = FailureState( - sendViewModel.translateErrorMessage(e, walletType, sendViewModel.wallet.currency)); - } - } - - @action - Future confirmSending() async { - final cryptoPaymentData = this.cryptoPaymentData; - try { - if (order == null || cryptoPaymentData == null) return; - - sendViewModel.clearOutputs(); - final output = sendViewModel.outputs.first; - output.address = cryptoPaymentData.address; - output.setCryptoAmount(cryptoPaymentData.price); - - await sendViewModel.createTransaction(); - } catch (e) { - throw e; - } - } - - @action - void updateRemainingTime() { - if (expirationTime == null) { - formattedRemainingTime = ''; - return; - } - - remainingTime = expirationTime!.difference(DateTime.now()); - - isOrderExpired = remainingTime!.isNegative; - - if (isOrderExpired) { - disposeExpirationTimer(); - sendViewModel.state = FailureState('Order has expired.'); - } else { - formattedRemainingTime = formatDuration(remainingTime!); - } - } - - void _startExpirationTimer() { - _timer?.cancel(); - _timer = Timer.periodic(Duration(seconds: 1), (_) { - updateRemainingTime(); - }); - } - - String formatDuration(Duration duration) { - final hours = duration.inHours; - final minutes = duration.inMinutes.remainder(60); - final seconds = duration.inSeconds.remainder(60); - return '${hours.toString().padLeft(2, '0')}:${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}'; - } - - void disposeExpirationTimer() { - _timer?.cancel(); - remainingTime = null; - formattedRemainingTime = ''; - expirationTime = null; - } - - void dispose() { - disposeExpirationTimer(); - } -} diff --git a/lib/view_model/dashboard/cake_features_view_model.dart b/lib/view_model/dashboard/cake_features_view_model.dart index 45f80bf80..d1a79ba4d 100644 --- a/lib/view_model/dashboard/cake_features_view_model.dart +++ b/lib/view_model/dashboard/cake_features_view_model.dart @@ -1,4 +1,4 @@ -import 'package:cake_wallet/cake_pay/cake_pay_service.dart'; +import 'package:cake_wallet/cake_pay/src/services/cake_pay_service.dart'; import 'package:mobx/mobx.dart'; part 'cake_features_view_model.g.dart'; diff --git a/lib/view_model/dashboard/dropdown_filter_item_widget.dart b/lib/view_model/dashboard/dropdown_filter_item_widget.dart index 45464b597..34149b8c4 100644 --- a/lib/view_model/dashboard/dropdown_filter_item_widget.dart +++ b/lib/view_model/dashboard/dropdown_filter_item_widget.dart @@ -1,67 +1,57 @@ -import 'package:auto_size_text/auto_size_text.dart'; import 'package:flutter/material.dart'; -class DropdownFilterList extends StatefulWidget { - DropdownFilterList({ +class DropdownFilterList extends StatelessWidget { + const DropdownFilterList({ Key? key, required this.items, - this.itemPrefix, - this.textStyle, - required this.caption, required this.selectedItem, required this.onItemSelected, + this.itemPrefix, }) : super(key: key); final List items; - final String? itemPrefix; - final TextStyle? textStyle; - final String caption; final String selectedItem; - final Function(String) onItemSelected; - - @override - _DropdownFilterListState createState() => _DropdownFilterListState(); -} - -class _DropdownFilterListState extends State { - String? selectedValue; - - @override - void initState() { - super.initState(); - selectedValue = widget.selectedItem; - } + final String? itemPrefix; + final ValueChanged onItemSelected; @override Widget build(BuildContext context) { return DropdownButtonHideUnderline( - child: Container( - child: DropdownButton( - isExpanded: true, - icon: Container( - child: Column( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - Icon(Icons.arrow_drop_down, color: Theme.of(context).colorScheme.onSurfaceVariant), - ], - ), - ), - dropdownColor: Theme.of(context).colorScheme.surfaceContainerHighest, + child: DropdownButton( + isDense: true, + dropdownColor: Theme.of(context).primaryColor, borderRadius: BorderRadius.circular(10), - items: widget.items - .map((item) => DropdownMenuItem( - alignment: Alignment.bottomCenter, - value: item, - child: AutoSizeText('${widget.itemPrefix ?? ''} $item', style: widget.textStyle), - )) + selectedItemBuilder: (context) => items + .map( + (item) => Text( + '${itemPrefix ?? ''} $item', + style: Theme.of(context).textTheme.titleMedium!, + maxLines: 1, + ), + ) .toList(), - value: selectedValue, - onChanged: (newValue) { - setState(() => selectedValue = newValue); - widget.onItemSelected(newValue!); + items: items + .map( + (item) => DropdownMenuItem( + value: item, + alignment: Alignment.centerLeft, + child: Text( + '${itemPrefix ?? ''} $item', + style: (const TextStyle()).copyWith( + color: Colors.white, + fontWeight: FontWeight.w600, + ), + maxLines: 1, + ), + ), + ) + .toList(), + value: selectedItem, + onChanged: (value) { + if (value != null) onItemSelected(value); }, - ), - ), + icon: Icon(Icons.keyboard_arrow_down_outlined, + color: Theme.of(context).colorScheme.onSurfaceVariant)), ); } } diff --git a/lib/view_model/send/output.dart b/lib/view_model/send/output.dart index 23efe8c1a..9926f7c8c 100644 --- a/lib/view_model/send/output.dart +++ b/lib/view_model/send/output.dart @@ -335,3 +335,29 @@ abstract class OutputBase with Store { note = parsedAddress.description; } } + +extension OutputCopyWith on Output { + Output OutputCopyWithParsedAddress({ + ParsedAddress? parsedAddress, + String? fiatAmount, + }) { + final clone = Output( + _wallet, + _settingsStore, + _fiatConversationStore, + cryptoCurrencyHandler, + ); + + clone + ..cryptoAmount = cryptoAmount + ..cryptoFullBalance = cryptoFullBalance + ..note = note + ..sendAll = sendAll + ..memo = memo + ..stealthAddress = stealthAddress + ..parsedAddress = parsedAddress ?? this.parsedAddress + ..fiatAmount = fiatAmount ?? this.fiatAmount; + + return clone; + } +} \ No newline at end of file diff --git a/res/values/strings_ar.arb b/res/values/strings_ar.arb index 71562539d..82c64614b 100644 --- a/res/values/strings_ar.arb +++ b/res/values/strings_ar.arb @@ -108,6 +108,7 @@ "cake_2fa_preset": " كعكة 2FA مسبقا", "cake_dark_theme": "موضوع الكعكة الظلام", "cake_pay_account_note": "قم بالتسجيل باستخدام عنوان بريد إلكتروني فقط لمشاهدة البطاقات وشرائها. حتى أن بعضها متوفر بسعر مخفض!", + "cake_pay_card_email_delivered_message": "سيتم تسليم بطاقة الهدايا الخاصة بك عبر البريد الإلكتروني بعد التأكيدات اللازمة.", "cake_pay_learn_more": "شراء واسترداد بطاقات الهدايا على الفور في التطبيق!\nاسحب من اليسار إلى اليمين لمعرفة المزيد.", "cake_pay_save_order": "يجب إرسال البطاقة إلى بريدك الإلكتروني خلال يوم عمل واحد \n حفظ معرف الطلب الخاص بك:", "cake_pay_subtitle": "شراء بطاقات مسبقة الدفع وبطاقات الهدايا في جميع أنحاء العالم", diff --git a/res/values/strings_bg.arb b/res/values/strings_bg.arb index f7edf0a1a..66f0e102f 100644 --- a/res/values/strings_bg.arb +++ b/res/values/strings_bg.arb @@ -108,6 +108,7 @@ "cake_2fa_preset": "Торта 2FA Preset", "cake_dark_theme": "Торта тъмна тема", "cake_pay_account_note": "Регистрайте се само с един имейл, за да виждате и купувате карти. За някои има дори и отстъпка!", + "cake_pay_card_email_delivered_message": "Вашата карта за подарък ще бъде доставена по имейл след необходимите потвърждения.", "cake_pay_learn_more": "Купете и използвайте гифткарти директно в приложението!\nПлъзнете отляво надясно, за да научите още.", "cake_pay_save_order": "Картата трябва да бъде изпратена до вашия имейл в рамките на 1 работен ден \n Запазете вашия идентификационен номер на поръчката:", "cake_pay_subtitle": "Купете предплатени карти и карти за подаръци в световен мащаб", diff --git a/res/values/strings_cs.arb b/res/values/strings_cs.arb index 761fba73e..daa5836fa 100644 --- a/res/values/strings_cs.arb +++ b/res/values/strings_cs.arb @@ -108,6 +108,7 @@ "cake_2fa_preset": "Předvolba Cake 2FA", "cake_dark_theme": "Dort tmavé téma", "cake_pay_account_note": "Přihlaste se svou e-mailovou adresou pro zobrazení a nákup karet. Některé jsou dostupné ve slevě!", + "cake_pay_card_email_delivered_message": "Vaše dárková karta bude doručena e -mailem po nezbytných potvrzeních.", "cake_pay_learn_more": "Okamžitý nákup a uplatnění dárkových karet v aplikaci!\nPřejeďte prstem zleva doprava pro další informace.", "cake_pay_save_order": "Karta by měla být odeslána do vašeho e-mailu do 1 pracovního dne \n Uložit ID objednávky:", "cake_pay_subtitle": "Kupte si celosvětové předplacené karty a dárkové karty", diff --git a/res/values/strings_de.arb b/res/values/strings_de.arb index 5f14b2562..c46872106 100644 --- a/res/values/strings_de.arb +++ b/res/values/strings_de.arb @@ -108,6 +108,7 @@ "cake_2fa_preset": "Cake 2FA-Voreinstellung", "cake_dark_theme": "Cake Dark Thema", "cake_pay_account_note": "Melden Sie sich nur mit einer E-Mail-Adresse an, um Karten anzuzeigen und zu kaufen. Einige sind sogar mit Rabatt erhältlich!", + "cake_pay_card_email_delivered_message": "Ihre Geschenkkarte wird nach den erforderlichen Bestätigungen per E -Mail geliefert.", "cake_pay_learn_more": "Kaufen und lösen Sie Geschenkkarten sofort in der App ein!\nWischen Sie von links nach rechts, um mehr zu erfahren.", "cake_pay_save_order": "Die Karte sollte innerhalb von 1 Werktag an Ihre E-Mail gesendet werden, \n Ihre Bestell-ID zu speichern:", "cake_pay_subtitle": "Kaufen Sie weltweite Prepaid-Karten und Geschenkkarten", @@ -605,8 +606,8 @@ "please_choose_one": "Bitte wählen Sie einen", "please_fill_totp": "Bitte geben Sie den 8-stelligen Code ein, der auf Ihrem anderen Gerät vorhanden ist", "please_make_selection": "Bitte treffen Sie unten eine Auswahl zum Erstellen oder Wiederherstellen Ihrer Wallet.", - "Please_reference_document": "Weitere Informationen finden Sie in den Dokumenten unten.", "please_reference_document": "Bitte verweisen Sie auf die folgenden Dokumente, um weitere Informationen zu erhalten.", + "Please_reference_document": "Weitere Informationen finden Sie in den Dokumenten unten.", "please_select": "Bitte auswählen:", "please_select_backup_file": "Bitte wählen Sie die Sicherungsdatei und geben Sie das Sicherungskennwort ein.", "please_try_to_connect_to_another_node": "Bitte versuchen Sie, sich mit einem anderen Knoten zu verbinden", @@ -1127,4 +1128,4 @@ "you_will_send": "Konvertieren von", "youCanGoBackToYourDapp": "Sie können jetzt zu Ihrem Dapp zurückkehren", "yy": "YY" -} +} \ No newline at end of file diff --git a/res/values/strings_en.arb b/res/values/strings_en.arb index 75096929a..5287ed915 100644 --- a/res/values/strings_en.arb +++ b/res/values/strings_en.arb @@ -108,6 +108,7 @@ "cake_2fa_preset": "Cake 2FA Preset", "cake_dark_theme": "Cake Dark Theme", "cake_pay_account_note": "Sign up with just an email address to see and purchase cards. Some are even available at a discount!", + "cake_pay_card_email_delivered_message": "Your gift card will be delivered via email after the necessary confirmations.", "cake_pay_learn_more": "Instantly purchase and redeem gift cards in the app!\nSwipe left to right to learn more.", "cake_pay_save_order": "The card should be sent to your e-mail within 1 business day \n Save your Order ID:", "cake_pay_subtitle": "Buy worldwide prepaid cards and gift cards", diff --git a/res/values/strings_es.arb b/res/values/strings_es.arb index 3532af6fd..7a72ac6b5 100644 --- a/res/values/strings_es.arb +++ b/res/values/strings_es.arb @@ -108,6 +108,7 @@ "cake_2fa_preset": "Pastel 2FA preestablecido", "cake_dark_theme": "Tema oscuro", "cake_pay_account_note": "Regístrate con solo una dirección de correo electrónico para ver y comprar tarjetas. ¡Algunas incluso están disponibles con descuento!", + "cake_pay_card_email_delivered_message": "Su tarjeta de regalo se entregará por correo electrónico después de las confirmaciones necesarias.", "cake_pay_learn_more": "¡Compra y canjea tarjetas de regalo al instante en la aplicación!\nDesliza el dedo de izquierda a derecha para obtener más información.", "cake_pay_save_order": "La tarjeta debe enviarse a tu correo electrónico dentro de 1 día hábil \n Guardar su ID de pedido:", "cake_pay_subtitle": "Compra tarjetas prepagadas y tarjetas de regalo en todo el mundo", diff --git a/res/values/strings_fr.arb b/res/values/strings_fr.arb index e77081ab7..dc24a7384 100644 --- a/res/values/strings_fr.arb +++ b/res/values/strings_fr.arb @@ -108,6 +108,7 @@ "cake_2fa_preset": "Cake 2FA prédéfini", "cake_dark_theme": "Thème sombre Cake", "cake_pay_account_note": "Inscrivez-vous avec juste une adresse e-mail pour voir et acheter des cartes. Certaines sont même disponibles à prix réduit !", + "cake_pay_card_email_delivered_message": "Votre carte-cadeau sera livrée par e-mail après les confirmations nécessaires.", "cake_pay_learn_more": "Achetez et utilisez instantanément des cartes-cadeaux dans l'application !\nBalayer de gauche à droite pour en savoir plus.", "cake_pay_save_order": "La carte doit être envoyée à votre e-mail dans un jour ouvrable \n Enregistrez votre identifiant de commande:", "cake_pay_subtitle": "Achetez des cartes et des cartes-cadeaux prépayées mondiales", diff --git a/res/values/strings_ha.arb b/res/values/strings_ha.arb index 29456991f..55fd6affc 100644 --- a/res/values/strings_ha.arb +++ b/res/values/strings_ha.arb @@ -108,6 +108,7 @@ "cake_2fa_preset": "Cake 2FA saiti", "cake_dark_theme": "Cake Dark Jigo", "cake_pay_account_note": "Yi rajista tare da adireshin imel kawai don gani da siyan katunan. Wasu ma suna samuwa a rangwame!", + "cake_pay_card_email_delivered_message": "Za a kawo katin kyautar ku ta hanyar imel bayan abubuwan da suka wajaba.", "cake_pay_learn_more": "Nan take siya ku kwaso katunan kyaututtuka a cikin app!\nTake hagu zuwa dama don ƙarin koyo.", "cake_pay_save_order": "Ya kamata a aika katin zuwa e-mail ɗinku a cikin rana 1 na kasuwanci \n Ajiye id ku:", "cake_pay_subtitle": "Sayi katunan shirye-shiryen duniya da katunan kyauta", diff --git a/res/values/strings_hi.arb b/res/values/strings_hi.arb index 7823df582..a758041ae 100644 --- a/res/values/strings_hi.arb +++ b/res/values/strings_hi.arb @@ -108,6 +108,7 @@ "cake_2fa_preset": "केक 2एफए प्रीसेट", "cake_dark_theme": "केक डार्क थीम", "cake_pay_account_note": "कार्ड देखने और खरीदने के लिए केवल एक ईमेल पते के साथ साइन अप करें। कुछ छूट पर भी उपलब्ध हैं!", + "cake_pay_card_email_delivered_message": "आवश्यक पुष्टि के बाद आपका उपहार कार्ड ईमेल के माध्यम से दिया जाएगा।", "cake_pay_learn_more": "ऐप में उपहार कार्ड तुरंत खरीदें और रिडीम करें!\nअधिक जानने के लिए बाएं से दाएं स्वाइप करें।", "cake_pay_save_order": "कार्ड को आपके ई-मेल को 1 व्यावसायिक दिन के भीतर भेजा जाना चाहिए \n आपकी ऑर्डर आईडी सहेजें:", "cake_pay_subtitle": "दुनिया भर में प्रीपेड कार्ड और उपहार कार्ड खरीदें", diff --git a/res/values/strings_hr.arb b/res/values/strings_hr.arb index 6e84ce97b..67e677bf0 100644 --- a/res/values/strings_hr.arb +++ b/res/values/strings_hr.arb @@ -108,6 +108,7 @@ "cake_2fa_preset": "Cake 2FA Preset", "cake_dark_theme": "TOKA DARKA TEMA", "cake_pay_account_note": "Prijavite se samo s adresom e-pošte da biste vidjeli i kupili kartice. Neke su čak dostupne uz popust!", + "cake_pay_card_email_delivered_message": "Vaša poklon kartica bit će isporučena putem e -pošte nakon potrebnih potvrda.", "cake_pay_learn_more": "Azonnal vásárolhat és válthat be ajándékutalványokat az alkalmazásban!\nTovábbi információért csúsztassa balról jobbra az ujját.", "cake_pay_save_order": "Karticu treba poslati na vašu e-poštu u roku od 1 radnog dana \n Spremi ID narudžbe:", "cake_pay_subtitle": "Kupite svjetske unaprijed plaćene kartice i poklon kartice", diff --git a/res/values/strings_hy.arb b/res/values/strings_hy.arb index b888a3838..46eb69490 100644 --- a/res/values/strings_hy.arb +++ b/res/values/strings_hy.arb @@ -108,6 +108,7 @@ "cake_2fa_preset": "Cake 2FA նախապես կանխորոշված", "cake_dark_theme": "Cake մութ տեսք", "cake_pay_account_note": "Գրանցվեք միայն էլ. փոստի միջոցով, որպեսզի տեսնեք և գնեք քարտեր: Որոշ քարտեր հասանելի են նույնիսկ զեղչով:", + "cake_pay_card_email_delivered_message": "Ձեր նվեր քարտը կուղարկվի էլփոստի միջոցով անհրաժեշտ հաստատումներից հետո:", "cake_pay_learn_more": "Վայրկյանապես գնեք և փոխանակեք նվեր քարտերը հավելվածում:\nՍահեցրեք ձախից աջ՝ ավելին իմանալու համար:", "cake_pay_save_order": "Քարտը պետք է ուղարկված լինի ձեր էլ. փոստին 1 աշխատանքային օրվա ընթացքում \n Պահպանեք Ձեր պատվերի համարը՝", "cake_pay_subtitle": "Գնեք համաշխարհային նախավճարային քարտեր և նվեր քարտեր", diff --git a/res/values/strings_id.arb b/res/values/strings_id.arb index 0caf6147f..a1e6018df 100644 --- a/res/values/strings_id.arb +++ b/res/values/strings_id.arb @@ -108,6 +108,7 @@ "cake_2fa_preset": "Preset Kue 2FA", "cake_dark_theme": "Tema Kue Gelap", "cake_pay_account_note": "Daftar hanya dengan alamat email untuk melihat dan membeli kartu. Beberapa di antaranya bahkan tersedia dengan diskon!", + "cake_pay_card_email_delivered_message": "Kartu hadiah Anda akan dikirimkan melalui email setelah konfirmasi yang diperlukan.", "cake_pay_learn_more": "Beli dan tukar kartu hadiah secara instan di aplikasi!\nGeser ke kanan untuk informasi lebih lanjut.", "cake_pay_save_order": "Kartu harus dikirim ke email Anda dalam 1 hari kerja \n Simpan ID pesanan Anda:", "cake_pay_subtitle": "Beli kartu prabayar di seluruh dunia dan kartu hadiah", diff --git a/res/values/strings_it.arb b/res/values/strings_it.arb index 6aacff2c2..3ab18f7b9 100644 --- a/res/values/strings_it.arb +++ b/res/values/strings_it.arb @@ -108,6 +108,7 @@ "cake_2fa_preset": "Preset Cake 2FA", "cake_dark_theme": "Tema scuro Cake", "cake_pay_account_note": "Iscriviti solamente con un indirizzo email per vedere e acquistare le carte. Alcune sono anche disponibili con uno sconto!", + "cake_pay_card_email_delivered_message": "La tua carta regalo verrà consegnata via e -mail dopo le conferme necessarie.", "cake_pay_learn_more": "Acquista e riscatta istantaneamente carte regalo nell'app!\nScorri da sinistra a destra per saperne di più.", "cake_pay_save_order": "La carta deve essere inviata alla tua e-mail entro 1 giorno lavorativo \n Salva l'ID del tuo ordine:", "cake_pay_subtitle": "Acquista carte prepagate e carte regalo internazionali", diff --git a/res/values/strings_ja.arb b/res/values/strings_ja.arb index b807ce6ee..96a2d0019 100644 --- a/res/values/strings_ja.arb +++ b/res/values/strings_ja.arb @@ -108,6 +108,7 @@ "cake_2fa_preset": "ケーキ 2FA プリセット", "cake_dark_theme": "ケーキ暗いテーマ", "cake_pay_account_note": "メールアドレスだけでサインアップして、カードを表示して購入できます。割引価格で利用できるカードもあります!", + "cake_pay_card_email_delivered_message": "ギフトカードは、必要な確認の後に電子メールで配信されます。", "cake_pay_learn_more": "アプリですぐにギフトカードを購入して引き換えましょう!\n左から右にスワイプして詳細をご覧ください。", "cake_pay_save_order": "カードは1営業日以内に電子メールに送信する必要があります\n注文IDを保存します。", "cake_pay_subtitle": "世界中のプリペイドカードとギフトカードを購入します", diff --git a/res/values/strings_ko.arb b/res/values/strings_ko.arb index 4c4efcb99..27fa0e894 100644 --- a/res/values/strings_ko.arb +++ b/res/values/strings_ko.arb @@ -108,6 +108,7 @@ "cake_2fa_preset": "Cake 2FA 사전 설정", "cake_dark_theme": "Cake 다크 테마", "cake_pay_account_note": "이메일 주소만으로 가입하여 카드를 확인하고 구매하세요. 일부는 할인된 가격으로도 이용 가능합니다!", + "cake_pay_card_email_delivered_message": "기프트 카드는 필요한 확인 후 이메일을 통해 전달됩니다.", "cake_pay_learn_more": "앱에서 즉시 기프트 카드를 구매하고 사용하세요!\n자세히 알아보려면 왼쪽에서 오른쪽으로 스와이프하세요.", "cake_pay_save_order": "카드는 영업일 기준 1일 이내에 이메일로 발송됩니다.\n주문 ID를 저장하세요:", "cake_pay_subtitle": "전 세계 선불 카드 및 기프트 카드 구매", diff --git a/res/values/strings_my.arb b/res/values/strings_my.arb index f5ceb28be..a8bcd850f 100644 --- a/res/values/strings_my.arb +++ b/res/values/strings_my.arb @@ -108,6 +108,7 @@ "cake_2fa_preset": "ကိတ်မုန့် 2FA ကြိုတင်သတ်မှတ်", "cake_dark_theme": "ကိတ်မုန့် Dark Theme", "cake_pay_account_note": "ကတ်များကြည့်ရှုဝယ်ယူရန် အီးမေးလ်လိပ်စာတစ်ခုဖြင့် စာရင်းသွင်းပါ။ အချို့ကို လျှော့ဈေးဖြင့်ပင် ရနိုင်သည်။", + "cake_pay_card_email_delivered_message": "လိုအပ်သောအတည်ပြုချက်များအပြီးတွင်သင်၏လက်ဆောင်ကဒ်ကိုအီးမေးလ်ဖြင့်ပေးပို့လိမ့်မည်။", "cake_pay_learn_more": "အက်ပ်ရှိ လက်ဆောင်ကတ်များကို ချက်ချင်းဝယ်ယူပြီး ကူပွန်ဖြင့် လဲလှယ်ပါ။\nပိုမိုလေ့လာရန် ဘယ်မှညာသို့ ပွတ်ဆွဲပါ။", "cake_pay_save_order": "ကဒ်ကိုသင်၏အီးမေးလ်သို့ပေးပို့သင့်သည်။ သင်၏အမှာစာ ID ကိုသိမ်းပါ။", "cake_pay_subtitle": "Worldwide ကြိုတင်ငွေဖြည့်ကဒ်များနှင့်လက်ဆောင်ကဒ်များကို 0 ယ်ပါ", diff --git a/res/values/strings_nl.arb b/res/values/strings_nl.arb index c7318cefb..3934be573 100644 --- a/res/values/strings_nl.arb +++ b/res/values/strings_nl.arb @@ -108,6 +108,7 @@ "cake_2fa_preset": "Taart 2FA Voorinstelling", "cake_dark_theme": "Cake Dark Theme", "cake_pay_account_note": "Meld u aan met alleen een e-mailadres om kaarten te bekijken en te kopen. Sommige zijn zelfs met korting verkrijgbaar!", + "cake_pay_card_email_delivered_message": "Uw cadeaubon wordt na de nodige bevestigingen via e -mail afgeleverd.", "cake_pay_learn_more": "Koop en wissel cadeaubonnen direct in de app in!\nSwipe van links naar rechts voor meer informatie.", "cake_pay_save_order": "De kaart moet binnen 1 werkdag naar uw e-mail worden verzonden.", "cake_pay_subtitle": "Koop wereldwijde prepaid -kaarten en cadeaubonnen", diff --git a/res/values/strings_pl.arb b/res/values/strings_pl.arb index d2c0a417a..810f70500 100644 --- a/res/values/strings_pl.arb +++ b/res/values/strings_pl.arb @@ -108,6 +108,7 @@ "cake_2fa_preset": "Cake 2FA Preset", "cake_dark_theme": "Cake Dark", "cake_pay_account_note": "Zarejestruj się, używając tylko adresu e-mail, aby przeglądać i kupować karty. Niektóre są nawet dostępne ze zniżką!", + "cake_pay_card_email_delivered_message": "Twoja karta podarunkowa zostanie dostarczona pocztą elektroniczną po niezbędnych potwierdzeniach.", "cake_pay_learn_more": "Kupuj i wykorzystuj karty podarunkowe od razu w aplikacji!\nPrzesuń od lewej do prawej, aby dowiedzieć się więcej.", "cake_pay_save_order": "Karta powinna zostać wysłana na adres e-mail w ciągu 1 dnia roboczego \n Zapisz identyfikator zamówienia:", "cake_pay_subtitle": "Kup na całym świecie karty przedpłacone i karty podarunkowe", diff --git a/res/values/strings_pt.arb b/res/values/strings_pt.arb index e5bc95a99..423ff9800 100644 --- a/res/values/strings_pt.arb +++ b/res/values/strings_pt.arb @@ -108,6 +108,7 @@ "cake_2fa_preset": "Predefinição de bolo 2FA", "cake_dark_theme": "Bolo tema escuro", "cake_pay_account_note": "Inscreva-se com apenas um endereço de e-mail para ver e comprar cartões. Alguns estão até com desconto!", + "cake_pay_card_email_delivered_message": "Seu cartão -presente será entregue por e -mail após as confirmações necessárias.", "cake_pay_learn_more": "Compre e resgate vales-presente instantaneamente no app!\nDeslize da esquerda para a direita para saber mais.", "cake_pay_save_order": "O cartão deve ser enviado ao seu e-mail dentro de 1 dia útil \n Salvar seu ID do pedido:", "cake_pay_subtitle": "Compre cartões pré -pagos em todo o mundo e cartões -presente", diff --git a/res/values/strings_ru.arb b/res/values/strings_ru.arb index 536596b09..4563d4f0e 100644 --- a/res/values/strings_ru.arb +++ b/res/values/strings_ru.arb @@ -108,6 +108,7 @@ "cake_2fa_preset": "Торт 2FA Preset", "cake_dark_theme": "Тейт темная тема", "cake_pay_account_note": "Зарегистрируйтесь, указав только адрес электронной почты, чтобы просматривать и покупать карты. Некоторые даже доступны со скидкой!", + "cake_pay_card_email_delivered_message": "Ваша подарочная карта будет доставлена ​​по электронной почте после необходимых подтверждений.", "cake_pay_learn_more": "Мгновенно покупайте и используйте подарочные карты в приложении!\nПроведите по экрану слева направо, чтобы узнать больше.", "cake_pay_save_order": "Карта должна быть отправлена ​​на ваше электронное письмо в течение 1 рабочего дня \n Сохраните свой идентификатор заказа:", "cake_pay_subtitle": "Купить карты с предоплатой и подарочными картами по всему миру", diff --git a/res/values/strings_th.arb b/res/values/strings_th.arb index 13a909c1c..adeb7169d 100644 --- a/res/values/strings_th.arb +++ b/res/values/strings_th.arb @@ -108,6 +108,7 @@ "cake_2fa_preset": "เค้ก 2FA ที่ตั้งไว้ล่วงหน้า", "cake_dark_theme": "ธีมเค้กมืด", "cake_pay_account_note": "ลงทะเบียนด้วยอีเมลเพียงอย่างเดียวเพื่อดูและซื้อบัตร บางบัตรอาจมีส่วนลด!", + "cake_pay_card_email_delivered_message": "บัตรของขวัญของคุณจะถูกส่งทางอีเมลหลังจากการยืนยันที่จำเป็น", "cake_pay_learn_more": "ซื้อและเบิกบัตรของขวัญในแอพพลิเคชันทันที!\nกระแทกขวาไปซ้ายเพื่อเรียนรู้เพิ่มเติม", "cake_pay_save_order": "บัตรควรส่งไปยังอีเมลของคุณภายใน 1 วันทำการ \n บันทึกรหัสคำสั่งซื้อของคุณ:", "cake_pay_subtitle": "ซื้อบัตรเติมเงินและบัตรของขวัญทั่วโลก", diff --git a/res/values/strings_tl.arb b/res/values/strings_tl.arb index 92549cb38..18d783306 100644 --- a/res/values/strings_tl.arb +++ b/res/values/strings_tl.arb @@ -108,6 +108,7 @@ "cake_2fa_preset": "Cake 2FA Preset", "cake_dark_theme": "Cake Dark Theme", "cake_pay_account_note": "Mag-sign up na may isang email address lamang upang makita at bumili ng mga kard. Ang ilan ay magagamit kahit sa isang diskwento!", + "cake_pay_card_email_delivered_message": "Ang iyong gift card ay maihatid sa pamamagitan ng email pagkatapos ng kinakailangang mga kumpirmasyon.", "cake_pay_learn_more": "Agad na bumili at tubusin ang mga kard ng regalo sa app!\nMag-swipe pakaliwa sa kanan upang matuto nang higit pa.", "cake_pay_save_order": "Ang card ay dapat ipadala sa iyong email sa loob ng 1 araw ng negosyo \n I-save ang iyong order ID:", "cake_pay_subtitle": "Bumili ng mga pandaigdigang prepaid card at gift card", diff --git a/res/values/strings_tr.arb b/res/values/strings_tr.arb index 7391e952e..d312e98fe 100644 --- a/res/values/strings_tr.arb +++ b/res/values/strings_tr.arb @@ -108,6 +108,7 @@ "cake_2fa_preset": "Kek 2FA Ön Ayarı", "cake_dark_theme": "Kek Koyu Tema", "cake_pay_account_note": "Kartları görmek ve satın almak için sadece bir e-posta adresiyle kaydolun. Hatta bazıları indirimli olarak bile mevcut!", + "cake_pay_card_email_delivered_message": "Hediye kartınız gerekli onaylardan sonra e -posta yoluyla teslim edilecektir.", "cake_pay_learn_more": "Uygulamada anında hediye kartları satın alın ve harcayın!\nDaha fazla öğrenmek için soldan sağa kaydır.", "cake_pay_save_order": "Kart, 1 İş Günü içinde e-postanıza gönderilmelidir \n Sipariş Kimliğinizi Kaydet:", "cake_pay_subtitle": "Dünya çapında ön ödemeli kartlar ve hediye kartları satın alın", diff --git a/res/values/strings_uk.arb b/res/values/strings_uk.arb index c02b853a7..8a71a9025 100644 --- a/res/values/strings_uk.arb +++ b/res/values/strings_uk.arb @@ -108,6 +108,7 @@ "cake_2fa_preset": "Торт 2FA Preset", "cake_dark_theme": "Темна тема торта", "cake_pay_account_note": "Зареєструйтеся, використовуючи лише адресу електронної пошти, щоб переглядати та купувати картки. Деякі навіть доступні зі знижкою!", + "cake_pay_card_email_delivered_message": "Ваша подарункова картка буде доставлена ​​електронною поштою після необхідних підтверджень.", "cake_pay_learn_more": "Миттєво купуйте та активуйте подарункові картки в додатку!\nПроведіть пальцем зліва направо, щоб дізнатися більше.", "cake_pay_save_order": "Картка повинна бути надіслана на вашу електронну пошту протягом 1 робочого дня \n Зберегти ідентифікатор замовлення:", "cake_pay_subtitle": "Купіть у всьому світі передплачені картки та подарункові картки", diff --git a/res/values/strings_ur.arb b/res/values/strings_ur.arb index 6bebc5027..3c9ed3ca0 100644 --- a/res/values/strings_ur.arb +++ b/res/values/strings_ur.arb @@ -108,6 +108,7 @@ "cake_2fa_preset": "کیک 2FA پیش سیٹ", "cake_dark_theme": "کیک ڈارک تھیم", "cake_pay_account_note": "کارڈز دیکھنے اور خریدنے کے لیے صرف ایک ای میل ایڈریس کے ساتھ سائن اپ کریں۔ کچھ رعایت پر بھی دستیاب ہیں!", + "cake_pay_card_email_delivered_message": "آپ کا گفٹ کارڈ ضروری تصدیقوں کے بعد ای میل کے ذریعے فراہم کیا جائے گا۔", "cake_pay_learn_more": "ایپ میں فوری طور پر گفٹ کارڈز خریدیں اور بھنائیں!\\nمزید جاننے کے لیے بائیں سے دائیں سوائپ کریں۔", "cake_pay_save_order": "کارڈ 1 کاروباری دن کے اندر آپ کے ای میل پر بھیجا جانا چاہئے \n اپنے آرڈر کی شناخت کو بچائیں:", "cake_pay_subtitle": "دنیا بھر میں پری پیڈ کارڈز اور گفٹ کارڈ خریدیں", diff --git a/res/values/strings_vi.arb b/res/values/strings_vi.arb index 4fc15425e..a92de3214 100644 --- a/res/values/strings_vi.arb +++ b/res/values/strings_vi.arb @@ -108,6 +108,7 @@ "cake_2fa_preset": "Thiết lập sẵn Cake 2FA", "cake_dark_theme": "Chủ đề Cake tối", "cake_pay_account_note": "Đăng ký chỉ với một địa chỉ email để xem và mua thẻ. Một số thẻ còn được giảm giá!", + "cake_pay_card_email_delivered_message": "Thẻ quà tặng của bạn sẽ được gửi qua email sau khi xác nhận cần thiết.", "cake_pay_learn_more": "Mua và đổi thẻ quà tặng ngay trong ứng dụng!\nVuốt từ trái sang phải để tìm hiểu thêm.", "cake_pay_save_order": "Thẻ sẽ được gửi đến email của bạn trong vòng 1 ngày làm việc \n Lưu mã Đơn hàng của bạn:", "cake_pay_subtitle": "Mua thẻ trả trước toàn cầu và thẻ quà tặng", diff --git a/res/values/strings_yo.arb b/res/values/strings_yo.arb index 53695e326..c770daf39 100644 --- a/res/values/strings_yo.arb +++ b/res/values/strings_yo.arb @@ -108,6 +108,7 @@ "cake_2fa_preset": "Cake 2FA Tito", "cake_dark_theme": "Akara oyinbo dudu koko", "cake_pay_account_note": "Ẹ fi àdírẹ́sì ímeèlì nìkan forúkọ sílẹ̀ k'ẹ́ rí àti ra àwọn káàdì. Ẹ lè fi owó tó kéré jù ra àwọn káàdì kan!", + "cake_pay_card_email_delivered_message": "Kaadi Ẹbun Rẹ yoo fi jiṣẹ nipasẹ imeeli lẹhin awọn ijẹrisi pataki.", "cake_pay_learn_more": "Láìpẹ́ ra àti lo àwọn káàdí ìrajà t'á lò nínú irú kan ìtajà nínú áàpù!\nẸ tẹ̀ òsì de ọ̀tún láti kọ́ jù.", "cake_pay_save_order": "Kaadi yẹ ki o firanṣẹ si imeeli rẹ laarin ọjọ iṣowo 1 \n Fipamọ aṣẹ rẹ:", "cake_pay_subtitle": "Ra awọn kaadi ti a san ni agbaye ati awọn kaadi ẹbun", diff --git a/res/values/strings_zh.arb b/res/values/strings_zh.arb index fc09bdc01..e90424082 100644 --- a/res/values/strings_zh.arb +++ b/res/values/strings_zh.arb @@ -108,6 +108,7 @@ "cake_2fa_preset": "蛋糕 2FA 预设", "cake_dark_theme": "蛋糕黑暗主题", "cake_pay_account_note": "只需使用電子郵件地址註冊即可查看和購買卡片。有些甚至可以打折!", + "cake_pay_card_email_delivered_message": "您的礼品卡将在必要的确认后通过电子邮件发送。", "cake_pay_learn_more": "立即在应用中购买和兑换礼品卡!\n从左向右滑动以了解详情。", "cake_pay_save_order": "该卡应在1个工作日内发送到您的电子邮件\n保存您的订单ID:", "cake_pay_subtitle": "购买全球预付费卡和礼品卡",