Cw 973 in app gift card redemption flow plus UI (#2285)

* refactor: cleanup cake_pay folder

* refactor: add My Cards tab on the main screen

* ui: add user card item

* refactor: cakePay purchase flow

* feat: add user card info

* refactor: auth flow

* refactor: theme data

* revert

* Update qr_widget.dart

* fix: update with new themes

* minor fixes

* feat: put redemption flow under a feature flag

* feat: implement simulated purchasing flow in debug mode

* feat: support LTC and MWEB payments

* Update savings_page.dart

* fix: UI fixes

* add Cake Pay transaction sent bottom sheet

* localization

* Update cake_pay_buy_card_page.dart

* addressing review comments

* fix marge conflicts
This commit is contained in:
Serhii
2025-07-11 23:56:35 +03:00
committed by GitHub
parent 203aff83f2
commit ef84db5874
96 changed files with 3907 additions and 2638 deletions
+5
View File
@@ -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';
@@ -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,
});
}
@@ -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';
@@ -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);
}
@@ -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<void> _onLoginSuccessful(BuildContext context, CakePayAuthViewModel authViewModel) async {
final verified = await Navigator.pushNamed<bool>(context, Routes.cakePayVerifyOtpPage,
arguments: [authViewModel.email, true]);
if (verified == true) {
Navigator.pop(context, true);
}
}
void _login() async {
if (_formKey.currentState != null && !_formKey.currentState!.validate()) {
@@ -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 {}
@@ -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<CakePayPaymentMethod> 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<void> alertIOSAvailability(BuildContext context, CakePayCard card) async {
cakePayBuyCardViewModel.isSimulatingFlow = false;
return await showPopUp<void>(
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<void> 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<void> _showconfirmPurchaseFirstAlert(BuildContext context) async {
if (!cakePayBuyCardViewModel.confirmsNoVpn ||
!cakePayBuyCardViewModel.confirmsVoidedRefund ||
!cakePayBuyCardViewModel.confirmsTermsAgreed) {
await showPopUp<void>(
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<void> 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<void>(
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<void>(
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<bool>(
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<Output>.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<void>(
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<void>(
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<void>(
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<void>(
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,
);
});
}
@@ -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<void> _handleOnPressed() async {
if (loggedIn) {
Navigator.pushNamed(context, Routes.cakePayAccountPage);
return;
}
final success = await Navigator.pushNamed<bool>(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<CakePayCardsPageBody> createState() => _CakePayCardsPageBodyState();
}
class _CakePayCardsPageBodyState extends State<CakePayCardsPageBody> {
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<void> showFilterWidget(
BuildContext context, CakePayCardsListViewModel cardsListViewModel) async {
return showPopUp<void>(
context: context,
builder: (BuildContext context) {
return FilterWidget(filterItems: cardsListViewModel.createFilterItems);
},
);
}
Future<void> showCountryPicker(
BuildContext context, CakePayCardsListViewModel cardsListViewModel) async {
await showPopUp<void>(
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<void> _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<void>(
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(),
);
}
@@ -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<OrderCard> 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<String, dynamic> 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<String, dynamic> map) {
return PaymentData(
btc: CryptoPaymentData.fromMap(map['BTC'] as Map<String, dynamic>),
btc_ln: CryptoPaymentData.fromMap(map['BTC_LN'] as Map<String, dynamic>),
xmr: CryptoPaymentData.fromMap(map['XMR'] as Map<String, dynamic>),
ltc: CryptoPaymentData.fromMap(map['LTC'] as Map<String, dynamic>),
ltc_mweb: CryptoPaymentData.fromMap(map['LTC_MWEB'] as Map<String, dynamic>),
invoiceTime: DateTime.fromMillisecondsSinceEpoch(map['invoice_time'] as int),
expirationTime: DateTime.fromMillisecondsSinceEpoch(map['expiration_time'] as int),
commission: map['commission'] as int?,
@@ -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<String, dynamic>;
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<String, dynamic>;
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 = <String, dynamic>{
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<String, dynamic>;
return CakePayOrder.fromMap(bodyJson);
} catch (e) {
throw Exception('${e}');
if (response.statusCode == 201) {
final data = json.decode(response.body) as Map<String, dynamic>;
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<void> simulatePayment(
Future<String> 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<String, dynamic>;
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(
@@ -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<void> simulatePayment({required String orderId}) async => await cakePayApi.simulatePayment(
Future<String> simulatePayment({required String orderId}) async => await cakePayApi.simulatePayment(
CSRFToken: CSRFToken, authorization: authorization, orderId: orderId);
}
@@ -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),
),
),
],
],
),
),
),
),
),
);
}
}
@@ -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<String> onSearch;
final VoidCallback onFilter;
final VoidCallback? onCountryPick;
final Country? selectedCountry;
final TextEditingController? controller;
@override
State<CakePaySearchBar> createState() => _CakePaySearchBarState();
}
class _CakePaySearchBarState extends State<CakePaySearchBar> {
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,
),
),
],
),
),
),
);
}
}
@@ -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)),
],
),
),
@@ -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<String> 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)),
),
],
)),
],
),
);
}
}
@@ -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),
],
),
);
}
}
@@ -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<FlipCard> 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;
}
}
@@ -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,
],
),
),
);
}
}
@@ -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,
),
],
),
@@ -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<ThreeCheckboxAlertContent> {
_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,
),
),
),
],
),
);
}
}
@@ -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)),
],
),
),
],
),
),
),
);
}
}
+8 -26
View File
@@ -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<void> setup({
getIt.registerFactory(() => CakePayAuthViewModel(cakePayService: getIt.get<CakePayService>()));
getIt.registerFactoryParam<CakePayPurchaseViewModel, PaymentCredential, CakePayCard>(
(PaymentCredential paymentCredential, CakePayCard card) {
return CakePayPurchaseViewModel(
cakePayService: getIt.get<CakePayService>(),
paymentCredential: paymentCredential,
card: card,
sendViewModel: getIt.get<SendViewModel>());
});
getIt.registerFactoryParam<CakePayBuyCardViewModel, CakePayVendor, void>(
(CakePayVendor vendor, _) {
return CakePayBuyCardViewModel(vendor: vendor);
return CakePayBuyCardViewModel(vendor: vendor,
cakePayService: getIt.get<CakePayService>(),
sendViewModel: getIt.get<SendViewModel>());
});
getIt.registerFactory(() => CakePayAccountViewModel(cakePayService: getIt.get<CakePayService>()));
@@ -1422,14 +1412,6 @@ Future<void> setup({
getIt.get<CakePayBuyCardViewModel>(param1: vendor), getIt.get<CakePayService>());
});
getIt
.registerFactoryParam<CakePayBuyCardDetailPage, List<dynamic>, void>((List<dynamic> args, _) {
final paymentCredential = args.first as PaymentCredential;
final card = args[1] as CakePayCard;
return CakePayBuyCardDetailPage(
getIt.get<CakePayPurchaseViewModel>(param1: paymentCredential, param2: card));
});
getIt.registerFactory(() => CakePayCardsPage(getIt.get<CakePayCardsListViewModel>()));
getIt.registerFactory(() => CakePayAccountPage(getIt.get<CakePayAccountViewModel>()));
+8 -14
View File
@@ -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<dynamic> handleRouteWithPlatformAwareness(
Route<T> handleRouteWithPlatformAwareness<T>(
Widget Function(BuildContext) builder, {
bool fullscreenDialog = false,
}) {
if (Platform.isIOS) {
return CupertinoPageRoute<void>(builder: builder, fullscreenDialog: fullscreenDialog);
return CupertinoPageRoute<T>(builder: builder, fullscreenDialog: fullscreenDialog);
} else {
return MaterialPageRoute<void>(builder: builder, fullscreenDialog: fullscreenDialog);
return MaterialPageRoute<T>(builder: builder, fullscreenDialog: fullscreenDialog);
}
}
@@ -708,25 +707,20 @@ Route<dynamic> createRoute(RouteSettings settings) {
(context) => getIt.get<CakePayBuyCardPage>(param1: args),
);
case Routes.cakePayBuyCardDetailPage:
final args = settings.arguments as List;
return handleRouteWithPlatformAwareness(
(context) => getIt.get<CakePayBuyCardDetailPage>(param1: args),
);
case Routes.cakePayWelcomePage:
return handleRouteWithPlatformAwareness(
return handleRouteWithPlatformAwareness<bool>(
(context) => getIt.get<CakePayWelcomePage>(),
);
case Routes.cakePayVerifyOtpPage:
final args = settings.arguments as List;
return handleRouteWithPlatformAwareness(
return handleRouteWithPlatformAwareness<bool>(
(context) => getIt.get<CakePayVerifyOtpPage>(param1: args),
);
case Routes.cakePayAccountPage:
return handleRouteWithPlatformAwareness(
return handleRouteWithPlatformAwareness<bool>(
(context) => getIt.get<CakePayAccountPage>(),
);
-1
View File
@@ -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';
-5
View File
@@ -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';
@@ -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<void> alertIOSAvailability(BuildContext context, CakePayCard card) async {
return await showPopUp<void>(
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<void> 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<String> 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)),
],
),
],
);
}
}
@@ -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<void> showFilterWidget(BuildContext context) async {
return showPopUp<void>(
context: context,
builder: (BuildContext context) {
return FilterWidget(filterItems: _cardsListViewModel.createFilterItems);
},
);
}
}
Future<void> showCountryPicker(
BuildContext context, CakePayCardsListViewModel cardsListViewModel) async {
await showPopUp<void>(
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<CakePayCardsPageBody> {
double get backgroundHeight => MediaQuery.of(context).size.height * 0.75;
double thumbHeight = 72;
bool get isAlwaysShowScrollThumb => merchantsList.isEmpty ? false : merchantsList.length > 3;
List<CakePayVendor> 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<Color>(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),
),
));
}
}
@@ -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<void>(
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<void> _showconfirmPurchaseFirstAlert(BuildContext context) async {
if (!cakePayPurchaseViewModel.confirmsNoVpn ||
!cakePayPurchaseViewModel.confirmsVoidedRefund ||
!cakePayPurchaseViewModel.confirmsTermsAgreed) {
await showPopUp<void>(
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<void> 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<void> 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<void>(
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<void> _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<void>(
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<void>(
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<void>(
context: context,
builder: (BuildContext context) {
return AlertWithOneAction(
alertTitle: title,
alertContent: content,
buttonText: S.of(context).ok,
buttonAction: () => Navigator.of(context).pop());
});
}
}
Future<void> showSentAlert(BuildContext context) async {
if (!context.mounted) {
return;
}
final order = cakePayPurchaseViewModel.order!.orderId;
final isCopy = await showPopUp<bool>(
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<ThreeCheckboxAlertContent> {
_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,
),
),
),
],
),
);
}
}
@@ -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,
),
),
)
],
),
),
);
}
}
@@ -1,11 +0,0 @@
import 'package:flutter/material.dart';
class CardMenu extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Container(
);
}
}
@@ -78,11 +78,14 @@ class _FilterWidgetState extends State<FilterWidget> {
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<FilterWidget> {
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()
@@ -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';
@@ -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<ExchangeTradeForm> {
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<ExchangeTradeForm> {
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<ExchangeTradeForm> {
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(
@@ -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,
),
);
}
@@ -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();
}
@@ -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(),
),
);
}
+66 -42
View File
@@ -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<void>(
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<void>(
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();
},
@@ -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 {
+14 -11
View File
@@ -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,
),
),
),
),
+10 -1
View File
@@ -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,
@@ -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: <Widget>[
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,
),
),
],
),
);
}
}
}
@@ -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<FlipCardState>();
@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<void>(
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))),
],
),
],
),
],
),
),
);
@@ -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),
],
),
],
),
),
);
}
}
@@ -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<Output> 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<Output> 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,
),
),
),
@@ -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<SimpleCheckbox> createState() => _SimpleCheckboxState();
}
class _SimpleCheckboxState extends State<SimpleCheckbox> {
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)),
),
);
}
}
@@ -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(
+33 -53
View File
@@ -56,67 +56,47 @@ class _NumberTextFieldState extends State<NumberTextField> {
@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);
+34
View File
@@ -0,0 +1,34 @@
import 'package:flutter/material.dart';
class SimpleCheckbox extends StatefulWidget {
SimpleCheckbox({this.onChanged});
final Function(bool)? onChanged;
@override
State<SimpleCheckbox> createState() => _SimpleCheckboxState();
}
class _SimpleCheckboxState extends State<SimpleCheckbox> {
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)),
),
);
}
}
@@ -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<StandardSlideButton> {
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<StandardSlideButton> {
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(
@@ -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<Tab> tabs;
final List<Widget> views;
final EdgeInsets tabBarPadding;
final TextStyle? labelStyle;
final TextStyle? unselectedLabelStyle;
final Color? indicatorColor;
@override
State<TabViewWrapper> createState() => _TabViewWrapperState();
}
class _TabViewWrapperState extends State<TabViewWrapper> 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,
),
),
],
);
}
}
+1
View File
@@ -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);
+55 -63
View File
@@ -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));
}
@@ -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';
@@ -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';
@@ -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<CakePayPaymentMethod> 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<void> 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<void> 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<void> 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();
}
}
@@ -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 = <CakePayVendor>[] {
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<CakePayVendor> CakePayVendorList;
Map<String, List<FilterItem>> 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<CakePayCard> 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<bool>? 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<CakePayCard> 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<void> getCountries() async {
try {
availableCountries = await cakePayService.getCountries();
@@ -143,6 +173,44 @@ abstract class CakePayCardsListViewModelBase with Store {
}
}
Future<void> 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<void> checkAuth() async {
authFuture = ObservableFuture(cakePayService.isLogged());
final logged = await authFuture!;
if (logged) {
username = await cakePayService.getUserEmail();
}
}
@action
Future<void> 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;
@@ -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<void> 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<void> 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();
}
}
@@ -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';
@@ -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<String> 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<DropdownFilterList> {
String? selectedValue;
@override
void initState() {
super.initState();
selectedValue = widget.selectedItem;
}
final String? itemPrefix;
final ValueChanged<String> onItemSelected;
@override
Widget build(BuildContext context) {
return DropdownButtonHideUnderline(
child: Container(
child: DropdownButton<String>(
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<String>(
isDense: true,
dropdownColor: Theme.of(context).primaryColor,
borderRadius: BorderRadius.circular(10),
items: widget.items
.map((item) => DropdownMenuItem<String>(
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<String>(
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)),
);
}
}
+26
View File
@@ -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;
}
}