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
@@ -0,0 +1,92 @@
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/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';
import 'package:flutter/material.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
class CakePayAccountPage extends BasePage {
CakePayAccountPage(this.cakePayAccountViewModel);
final CakePayAccountViewModel cakePayAccountViewModel;
@override
Widget leading(BuildContext context) {
return MergeSemantics(
child: SizedBox(
height: 37,
width: 37,
child: ButtonTheme(
minWidth: double.minPositive,
child: Semantics(
label: S.of(context).seed_alert_back,
child: TextButton(
style: ButtonStyle(
overlayColor: WidgetStateColor.resolveWith(
(states) => Colors.transparent),
),
onPressed: () => Navigator.pop(context),
child: backButton(context),
),
),
),
),
);
}
@override
Widget middle(BuildContext context) {
return Text(
S.current.account,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurface,
fontWeight: FontWeight.w600,
),
);
}
@override
Widget body(BuildContext context) {
return ScrollableWithBottomSection(
contentPadding: EdgeInsets.all(24),
content: Column(
children: [
SizedBox(height: 20),
Observer(
builder: (_) => Container(
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
width: 1.0,
color: Theme.of(context).colorScheme.outlineVariant,
),
),
),
child: CakePayTile(
title: S.of(context).email_address,
subTitle: cakePayAccountViewModel.email
),
),
),
],
),
bottomSectionPadding: EdgeInsets.all(30),
bottomSection: Column(
children: [
PrimaryButton(
color: Theme.of(context).colorScheme.primary,
textColor: Theme.of(context).colorScheme.onPrimary,
text: S.of(context).logout,
onPressed: () {
cakePayAccountViewModel.logout();
Navigator.pushNamedAndRemoveUntil(context, Routes.dashboard, (route) => false);
},
),
],
),
);
}
}
@@ -0,0 +1,155 @@
import 'package:cake_wallet/generated/i18n.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';
import 'package:cake_wallet/src/widgets/keyboard_done_button.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/show_pop_up.dart';
import 'package:cake_wallet/view_model/cake_pay/cake_pay_auth_view_model.dart';
import 'package:flutter/material.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:keyboard_actions/keyboard_actions.dart';
import 'package:mobx/mobx.dart';
class CakePayVerifyOtpPage extends BasePage {
CakePayVerifyOtpPage(this._authViewModel, this._email, this.isSignIn)
: _codeController = TextEditingController(),
_codeFocus = FocusNode() {
_codeController.addListener(() {
final otp = _codeController.text;
_authViewModel.otp = otp;
if (otp.length > 5) {
_authViewModel.otpState = CakePayOtpSendEnabled();
} else {
_authViewModel.otpState = CakePayOtpSendDisabled();
}
});
}
final CakePayAuthViewModel _authViewModel;
final bool isSignIn;
final String _email;
@override
Widget middle(BuildContext context) {
return Text(
S.current.verification,
style: textMediumSemiBold(
color: Theme.of(context).colorScheme.onSurface,
),
);
}
final TextEditingController _codeController;
final FocusNode _codeFocus;
@override
Widget body(BuildContext context) {
reaction((_) => _authViewModel.otpState, (CakePayOtpState state) {
if (state is CakePayOtpFailure) {
_onOtpFailure(context, state.error);
}
if (state is CakePayOtpSuccess) {
_onOtpSuccessful(context);
}
});
return KeyboardActions(
config: KeyboardActionsConfig(
keyboardActionsPlatform: KeyboardActionsPlatform.IOS,
keyboardBarColor: Theme.of(context).colorScheme.surface,
nextFocus: false,
actions: [
KeyboardActionsItem(
focusNode: _codeFocus,
toolbarButtons: [(_) => KeyboardDoneButton()],
),
]),
child: Container(
height: 0,
color: Theme.of(context).colorScheme.surface,
child: ScrollableWithBottomSection(
contentPadding: EdgeInsets.all(24),
content: Column(
children: [
BaseTextFormField(
hintText: S.of(context).enter_code,
keyboardType: TextInputType.numberWithOptions(signed: false, decimal: true),
focusNode: _codeFocus,
controller: _codeController,
onSubmit: (_) => _verify(),
),
SizedBox(height: 14),
Text(
S.of(context).fill_code,
style: Theme.of(context).textTheme.bodySmall!.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
SizedBox(height: 34),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(S.of(context).didnt_get_code),
SizedBox(width: 20),
InkWell(
onTap: () => _authViewModel.logIn(_email),
child: Text(
S.of(context).resend_code,
style: Theme.of(context).textTheme.bodyMedium!.copyWith(
color: Theme.of(context).colorScheme.primary,
),
),
),
],
),
],
),
bottomSectionPadding: EdgeInsets.symmetric(vertical: 36, horizontal: 24),
bottomSection: Column(
children: [
Column(
mainAxisAlignment: MainAxisAlignment.end,
children: <Widget>[
Observer(
builder: (_) => LoadingPrimaryButton(
text: S.of(context).continue_text,
onPressed: _verify,
isDisabled: _authViewModel.otpState is CakePayOtpSendDisabled,
isLoading: _authViewModel.otpState is CakePayOtpValidating,
color: Theme.of(context).colorScheme.primary,
textColor: Theme.of(context).colorScheme.onPrimary,
),
),
SizedBox(height: 20),
],
),
],
),
),
),
);
}
void _onOtpFailure(BuildContext context, String error) {
showPopUp<void>(
context: context,
builder: (BuildContext context) {
return AlertWithOneAction(
alertTitle: S.current.verification,
alertContent: error,
buttonText: S.of(context).ok,
buttonAction: () => Navigator.of(context).pop());
});
}
void _onOtpSuccessful(BuildContext context) =>
Navigator.pop(context, true);
void _verify() async => await _authViewModel.verifyEmail(_codeController.text);
}
@@ -0,0 +1,131 @@
import 'package:cake_wallet/core/email_validator.dart';
import 'package:cake_wallet/generated/i18n.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';
import 'package:cake_wallet/src/widgets/base_text_form_field.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/show_pop_up.dart';
import 'package:cake_wallet/view_model/cake_pay/cake_pay_auth_view_model.dart';
import 'package:flutter/material.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:mobx/mobx.dart';
class CakePayWelcomePage extends BasePage {
CakePayWelcomePage(this._authViewModel)
: _formKey = GlobalKey<FormState>(),
_emailController = TextEditingController() {
_emailController.text = _authViewModel.email;
_emailController.addListener(() => _authViewModel.email = _emailController.text);
}
final GlobalKey<FormState> _formKey;
final CakePayAuthViewModel _authViewModel;
final TextEditingController _emailController;
@override
Widget middle(BuildContext context) {
return Text(
S.current.welcome_to_cakepay,
style: textMediumSemiBold(
color: Theme.of(context).colorScheme.onSurface,
),
);
}
@override
Widget body(BuildContext context) {
reaction((_) => _authViewModel.userVerificationState, (CakePayUserVerificationState state) {
if (state is CakePayUserVerificationStateFailure) {
_onLoginUserFailure(context, state.error);
}
if (state is CakePayUserVerificationStateSuccess) {
_onLoginSuccessful(context, _authViewModel);
}
});
return ScrollableWithBottomSection(
contentPadding: EdgeInsets.all(24),
content: Column(
children: [
SizedBox(height: 90),
Form(
key: _formKey,
child: BaseTextFormField(
hintText: S.of(context).email_address,
keyboardType: TextInputType.emailAddress,
validator: EmailValidator(),
controller: _emailController,
onSubmit: (text) => _login(),
),
),
SizedBox(height: 20),
Text(
S.of(context).about_cake_pay,
style: textLarge(
color: Theme.of(context).colorScheme.onSurface,
),
),
SizedBox(height: 20),
Text(S.of(context).cake_pay_account_note,
style: textLarge(color: Theme.of(context).colorScheme.onSurface)),
],
),
bottomSectionPadding: EdgeInsets.symmetric(vertical: 36, horizontal: 24),
bottomSection: Column(
children: [
Column(
mainAxisAlignment: MainAxisAlignment.end,
children: <Widget>[
Observer(
builder: (_) => LoadingPrimaryButton(
text: S.of(context).login,
onPressed: _login,
isLoading:
_authViewModel.userVerificationState is CakePayUserVerificationStateLoading,
color: Theme.of(context).colorScheme.primary,
textColor: Theme.of(context).colorScheme.onPrimary,
),
),
SizedBox(
height: 20,
),
],
),
],
),
);
}
void _onLoginUserFailure(BuildContext context, String error) {
showPopUp<void>(
context: context,
builder: (BuildContext context) {
return AlertWithOneAction(
alertTitle: S.current.login,
alertContent: error,
buttonText: S.of(context).ok,
buttonAction: () => Navigator.of(context).pop());
});
}
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()) {
return;
}
await _authViewModel.logIn(_emailController.text);
}
}
+68
View File
@@ -0,0 +1,68 @@
abstract class CakePayUserVerificationState {}
class CakePayUserVerificationStateInitial extends CakePayUserVerificationState {}
class CakePayUserVerificationStateSuccess extends CakePayUserVerificationState {}
class CakePayUserVerificationStatePending extends CakePayUserVerificationState {}
class CakePayUserVerificationStateLoading extends CakePayUserVerificationState {}
class CakePayUserVerificationStateFailure extends CakePayUserVerificationState {
CakePayUserVerificationStateFailure({required this.error});
final String error;
}
abstract class CakePayOtpState {}
class CakePayOtpValidating extends CakePayOtpState {}
class CakePayOtpSuccess extends CakePayOtpState {}
class CakePayOtpSendDisabled extends CakePayOtpState {}
class CakePayOtpSendEnabled extends CakePayOtpState {}
class CakePayOtpFailure extends CakePayOtpState {
CakePayOtpFailure({required this.error});
final String error;
}
class CakePayCreateCardState {}
class CakePayCreateCardStateSuccess extends CakePayCreateCardState {}
class CakePayCreateCardStateLoading extends CakePayCreateCardState {}
class CakePayCreateCardStateFailure extends CakePayCreateCardState {
CakePayCreateCardStateFailure({required this.error});
final String error;
}
class UserCakePayCardsState {}
class UserCakePayCardsStateInitial extends UserCakePayCardsState {}
class UserCakePayCardsStateNoCards extends UserCakePayCardsState {}
class UserCakePayCardsStateFetching extends UserCakePayCardsState {}
class UserCakePayCardsStateFailure extends UserCakePayCardsState {
UserCakePayCardsStateFailure({required this.error});
final String error;
}
class UserCakePayCardsStateSuccess extends UserCakePayCardsState {}
abstract class CakePayVendorState {}
class InitialCakePayVendorLoadingState extends CakePayVendorState {}
class CakePayVendorLoadingState extends CakePayVendorState {}
class CakePayVendorLoadedState 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(),
);
}
@@ -0,0 +1,74 @@
import 'package:cake_wallet/entities/fiat_currency.dart';
class CakePayCard {
final int id;
final String name;
final String? description;
final String? termsAndConditions;
final String? howToUse;
final String? expiryAndValidity;
final String? cardImageUrl;
final String? country;
final FiatCurrency fiatCurrency;
final List<String> denominationsUsd;
final List<String> denominations;
final String? minValueUsd;
final String? maxValueUsd;
final String? minValue;
final String? maxValue;
CakePayCard({
required this.id,
required this.name,
this.description,
this.termsAndConditions,
this.howToUse,
this.expiryAndValidity,
this.cardImageUrl,
this.country,
required this.fiatCurrency,
required this.denominationsUsd,
required this.denominations,
this.minValueUsd,
this.maxValueUsd,
this.minValue,
this.maxValue,
});
factory CakePayCard.fromJson(Map<String, dynamic> json) {
final name = stripHtmlIfNeeded(json['name'] as String? ?? '');
final description = stripHtmlIfNeeded(json['description'] as String? ?? '');
final termsAndConditions = stripHtmlIfNeeded(json['terms_and_conditions'] as String? ?? '');
final howToUse = stripHtmlIfNeeded(json['how_to_use'] as String? ?? '');
final fiatCurrency = FiatCurrency.deserialize(raw: json['currency_code'] as String? ?? '');
final List<String> denominationsUsd =
(json['denominations_usd'] as List?)?.map((e) => e.toString()).toList() ?? [];
final List<String> denominations =
(json['denominations'] as List?)?.map((e) => e.toString()).toList() ?? [];
return CakePayCard(
id: json['id'] as int? ?? 0,
name: name,
description: description,
termsAndConditions: termsAndConditions,
howToUse: howToUse,
expiryAndValidity: json['expiry_and_validity'] as String?,
cardImageUrl: json['card_image_url'] as String?,
country: json['country'] as String?,
fiatCurrency: fiatCurrency,
denominationsUsd: denominationsUsd,
denominations: denominations,
minValueUsd: json['min_value_usd'] as String?,
maxValueUsd: json['max_value_usd'] as String?,
minValue: json['min_value'] as String?,
maxValue: json['max_value'] as String?,
);
}
static String stripHtmlIfNeeded(String text) {
return text.replaceAll(RegExp(r'<[^>]*>|&[^;]+;'), ' ');
}
}
+157
View File
@@ -0,0 +1,157 @@
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;
final String? externalId;
final double amountUsd;
final String status;
final String? vouchers;
final PaymentData paymentData;
CakePayOrder({
required this.orderId,
required this.cards,
required this.externalId,
required this.amountUsd,
required this.status,
required this.vouchers,
required this.paymentData,
});
factory CakePayOrder.fromMap(Map<String, dynamic> map) {
return CakePayOrder(
orderId: map['order_id'] as String,
cards: (map['cards'] as List<dynamic>)
.map((x) => OrderCard.fromMap(x as Map<String, dynamic>))
.toList(),
externalId: map['external_id'] as String?,
amountUsd: map['amount_usd'] as double,
status: map['status'] as String,
vouchers: map['vouchers'] as String?,
paymentData: PaymentData.fromMap(map['payment_data'] as Map<String, dynamic>));
}
}
class OrderCard {
final int cardId;
final int? externalId;
final String price;
final int quantity;
final String currencyCode;
final String? cardName;
final String? cardImagePath;
OrderCard({
required this.cardId,
required this.externalId,
required this.price,
required this.quantity,
required this.currencyCode,
required this.cardName,
required this.cardImagePath,
});
factory OrderCard.fromMap(Map<String, dynamic> map) {
return OrderCard(
cardId: map['card_id'] as int,
externalId: map['external_id'] as int?,
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,
});
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?,
);
}
}
class CryptoPaymentData {
final String price;
final PaymentUrl? paymentUrls;
final String address;
CryptoPaymentData({
required this.price,
this.paymentUrls,
required this.address,
});
factory CryptoPaymentData.fromMap(Map<String, dynamic> map) {
return CryptoPaymentData(
price: map['price'] as String,
paymentUrls: PaymentUrl.fromMap(map['paymentUrls'] as Map<String, dynamic>?),
address: map['address'] as String,
);
}
}
class PaymentUrl {
final String? bip21;
final String? bip72;
final String? bip72b;
final String? bip73;
final String? bolt11;
PaymentUrl({
this.bip21,
this.bip72,
this.bip72b,
this.bip73,
this.bolt11,
});
factory PaymentUrl.fromMap(Map<String, dynamic>? map) {
return PaymentUrl(
bip21: map?['BIP21'] as String?,
bip72: map?['BIP72'] as String?,
bip72b: map?['BIP72b'] as String?,
bip73: map?['BIP73'] as String?,
bolt11: map?['BOLT11'] as String?,
);
}
}
@@ -0,0 +1,6 @@
class CakePayUserCredentials {
const CakePayUserCredentials(this.username, this.token);
final String username;
final String token;
}
@@ -0,0 +1,47 @@
import 'cake_pay_card.dart';
class CakePayVendor {
final int id;
final String name;
final bool unavailable;
final String? cakeWarnings;
final String country;
final CakePayCard? card;
CakePayVendor({
required this.id,
required this.name,
required this.unavailable,
this.cakeWarnings,
required this.country,
this.card,
});
factory CakePayVendor.fromJson(Map<String, dynamic> json, String country) {
final name = stripHtmlIfNeeded(json['name'] as String);
var cardsJson = json['cards'] as List?;
CakePayCard? cardForVendor;
if (cardsJson != null && cardsJson.isNotEmpty) {
try {
cardForVendor = CakePayCard.fromJson(cardsJson
.where((element) => element['country'] == country)
.first as Map<String, dynamic>);
} catch (_) {}
}
return CakePayVendor(
id: json['id'] as int,
name: name,
unavailable: json['unavailable'] as bool? ?? false,
cakeWarnings: json['cake_warnings'] as String?,
country: country,
card: cardForVendor,
);
}
static String stripHtmlIfNeeded(String text) {
return text.replaceAll(RegExp(r'<[^>]*>|&[^;]+;'), ' ');
}
}
+281
View File
@@ -0,0 +1,281 @@
import 'dart:convert';
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';
class CakePayApi {
static const testBaseUri = false;
static const baseTestCakePayUri = 'test.cakepay.com';
static const baseProdCakePayUri = 'buy.cakepay.com';
static const baseCakePayUri = testBaseUri ? baseTestCakePayUri : baseProdCakePayUri;
static const vendorsPath = '/api/vendors';
static const countriesPath = '/api/countries';
static const authPath = '/api/auth';
static final verifyEmailPath = '/api/verify';
static final logoutPath = '/api/logout';
static final createOrderPath = '/api/order';
static final simulatePaymentPath = '/api/simulate_payment';
/// AuthenticateUser
Future<String> authenticateUser({required String email, required String apiKey}) async {
try {
final uri = Uri.https(baseCakePayUri, authPath);
final headers = {
'Accept': 'application/json',
'Content-Type': 'application/json',
'Authorization': 'Api-Key $apiKey',
};
final response = await ProxyWrapper().post(
clearnetUri: uri,
headers: headers,
body: json.encode({'email': email}),
);
if (response.statusCode != 200) {
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) {
return bodyJson['user']['email'] as String;
}
throw Exception('Failed to authenticate user with error: $bodyJson');
} catch (e) {
throw Exception('Failed to authenticate user with error: $e');
}
}
/// Verify email
Future<CakePayUserCredentials> verifyEmail({
required String email,
required String code,
required String apiKey,
}) async {
final uri = Uri.https(baseCakePayUri, verifyEmailPath);
final headers = {
'Accept': 'application/json',
'Content-Type': 'application/json',
'Authorization': 'Api-Key $apiKey',
};
final query = <String, String>{'email': email, 'otp': code};
final response = await ProxyWrapper().post(
clearnetUri: uri,
headers: headers,
body: json.encode(query),
);
if (response.statusCode != 200) {
throw Exception('Unexpected http status: ${response.statusCode}');
}
final bodyJson = json.decode(response.body) as Map<String, dynamic>;
if (bodyJson.containsKey('error')) {
throw Exception(bodyJson['error'] as String);
}
if (bodyJson.containsKey('token')) {
final token = bodyJson['token'] as String;
final userEmail = bodyJson['user']['email'] as String;
return CakePayUserCredentials(userEmail, token);
} else {
throw Exception('E-mail verification failed.');
}
}
/// createOrder
Future<CakePayOrder> createOrder({
required String apiKey,
required int cardId,
required String price,
required int quantity,
required String userEmail,
required String token,
required bool confirmsNoVpn,
required bool confirmsVoidedRefund,
required bool confirmsTermsAgreed,
}) async {
final uri = Uri.https(baseCakePayUri, createOrderPath);
final headers = {
'Accept': 'application/json',
'Content-Type': 'application/json',
'Authorization': 'Api-Key $apiKey',
};
final body = json.encode({
'card_id': cardId,
'price': price,
'quantity': quantity,
'user_email': userEmail,
'token': token,
'send_email': true,
'confirms_no_vpn': confirmsNoVpn,
'confirms_voided_refund': confirmsVoidedRefund,
'confirms_terms_agreed': confirmsTermsAgreed,
});
final response = await ProxyWrapper().post(
clearnetUri: uri,
headers: headers,
body: body,
);
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<String> simulatePayment(
{required String CSRFToken, required String authorization, required String orderId}) async {
final uri = Uri.https(baseCakePayUri, simulatePaymentPath + '/$orderId');
final headers = {
'accept': 'application/json',
'authorization': authorization,
'X-CSRFToken': CSRFToken,
};
final response = await ProxyWrapper().get(clearnetUri: uri, headers: headers);
printV('Response: ${response.statusCode}');
if (response.statusCode != 200) {
throw Exception('Unexpected http status: ${response.statusCode}');
}
final bodyJson = json.decode(response.body) as Map<String, dynamic>;
return 'You just SIMULATED a buying of a gift card with ID: ${bodyJson['order_id']}';
}
/// Logout
Future<void> logoutUser({required String email, required String apiKey}) async {
final uri = Uri.https(baseCakePayUri, logoutPath);
final headers = {
'Accept': 'application/json',
'Content-Type': 'application/json',
'Authorization': 'Api-Key $apiKey',
};
try {
final response = await ProxyWrapper().post(
clearnetUri: uri,
headers: headers,
body: json.encode({'email': email}),
);
if (response.statusCode != 200) {
throw Exception('Unexpected http status: ${response.statusCode}');
}
} catch (e) {
printV('Caught exception: $e');
}
}
/// Get Countries
Future<List<Country>> getCountries({required String apiKey}) async {
final uri = Uri.https(baseCakePayUri, countriesPath);
final headers = {
'accept': 'application/json',
'Authorization': 'Api-Key $apiKey',
};
final response = await ProxyWrapper().get(clearnetUri: uri, headers: headers);
if (response.statusCode != 200) {
throw Exception('Unexpected http status: ${response.statusCode}');
}
final bodyJson = json.decode(response.body) as List;
return bodyJson
.map<String>((country) => country['name'] as String)
.map((name) => Country.fromCakePayName(name))
.whereType<Country>()
.toList();
}
/// Get Vendors
Future<List<CakePayVendor>> getVendors({
required String apiKey,
required String country,
int? page,
String? countryCode,
String? search,
List<String>? vendorIds,
bool? giftCards,
bool? prepaidCards,
bool? onDemand,
bool? custom,
}) async {
var queryParams = {
'page': page?.toString(),
'country': country,
'country_code': countryCode,
'search': search,
'vendor_ids': vendorIds?.join(','),
'gift_cards': giftCards?.toString(),
'prepaid_cards': prepaidCards?.toString(),
'on_demand': onDemand?.toString(),
'custom': custom?.toString(),
};
final uri = Uri.https(baseCakePayUri, vendorsPath, queryParams);
var headers = {
'accept': 'application/json; charset=UTF-8',
'Content-Type': 'application/json; charset=UTF-8',
'Authorization': 'Api-Key $apiKey',
};
var response = await ProxyWrapper().get(clearnetUri: uri, headers: headers);
if (response.statusCode != 200) {
throw Exception(
'Failed to fetch vendors: statusCode - ${response.statusCode}, queryParams -$queryParams, response - ${response.body}');
}
final bodyJson = json.decode(response.body);
if (bodyJson is List<dynamic> && bodyJson.isEmpty) {
return [];
}
return (bodyJson['results'] as List)
.map((e) => CakePayVendor.fromJson(e as Map<String, dynamic>, country))
.toList();
}
}
@@ -0,0 +1,119 @@
import 'package:cake_wallet/.secrets.g.dart' as secrets;
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';
class CakePayService {
CakePayService(this.secureStorage, this.cakePayApi);
static const cakePayEmailStorageKey = 'cake_pay_email';
static const cakePayUsernameStorageKey = 'cake_pay_username';
static const cakePayUserTokenKey = 'cake_pay_user_token';
static String get testCakePayApiKey => secrets.testCakePayApiKey;
static String get cakePayApiKey => secrets.cakePayApiKey;
static String get CSRFToken => secrets.CSRFToken;
static String get authorization => secrets.authorization;
final SecureStorage secureStorage;
final CakePayApi cakePayApi;
/// Get Available Countries
Future<List<Country>> getCountries() async =>
await cakePayApi.getCountries(apiKey: cakePayApiKey);
/// Get Vendors
Future<List<CakePayVendor>> getVendors({
required String country,
int? page,
String? countryCode,
String? search,
List<String>? vendorIds,
bool? giftCards,
bool? prepaidCards,
bool? onDemand,
bool? custom,
}) async {
final result = await cakePayApi.getVendors(
apiKey: cakePayApiKey,
page: page,
country: country,
countryCode: countryCode,
search: search,
vendorIds: vendorIds,
giftCards: giftCards,
prepaidCards: prepaidCards,
onDemand: onDemand,
custom: custom);
return result;
}
/// LogIn
Future<void> logIn(String email) async {
final userName = await cakePayApi.authenticateUser(email: email, apiKey: cakePayApiKey);
await secureStorage.write(key: cakePayEmailStorageKey, value: userName);
await secureStorage.write(key: cakePayUsernameStorageKey, value: userName);
}
/// Verify email
Future<void> verifyEmail(String code) async {
final email = (await secureStorage.read(key: cakePayEmailStorageKey))!;
final credentials =
await cakePayApi.verifyEmail(email: email, code: code, apiKey: cakePayApiKey);
await secureStorage.write(key: cakePayUserTokenKey, value: credentials.token);
await secureStorage.write(key: cakePayUsernameStorageKey, value: credentials.username);
}
Future<String?> getUserEmail() async {
return (await secureStorage.read(key: cakePayEmailStorageKey));
}
/// Check is user logged
Future<bool> isLogged() async {
final username = await secureStorage.read(key: cakePayUsernameStorageKey) ?? '';
final password = await secureStorage.read(key: cakePayUserTokenKey) ?? '';
return username.isNotEmpty && password.isNotEmpty;
}
/// Logout
Future<void> logout([String? email]) async {
await secureStorage.delete(key: cakePayUsernameStorageKey);
await secureStorage.delete(key: cakePayUserTokenKey);
if (email != null) {
await cakePayApi.logoutUser(email: email, apiKey: cakePayApiKey);
}
}
/// Purchase Gift Card
Future<CakePayOrder> createOrder({
required int cardId,
required String price,
required int quantity,
required bool confirmsNoVpn,
required bool confirmsVoidedRefund,
required bool confirmsTermsAgreed,
}) async {
final userEmail = (await secureStorage.read(key: cakePayEmailStorageKey))!;
final token = (await secureStorage.read(key: cakePayUserTokenKey))!;
return await cakePayApi.createOrder(
apiKey: cakePayApiKey,
cardId: cardId,
price: price,
quantity: quantity,
token: token,
userEmail: userEmail,
confirmsNoVpn: confirmsNoVpn,
confirmsVoidedRefund: confirmsVoidedRefund,
confirmsTermsAgreed: confirmsTermsAgreed,
);
}
///Simulate Purchase Gift Card
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,
),
),
],
),
),
),
);
}
}
@@ -0,0 +1,47 @@
import 'package:flutter/material.dart';
class CakePayTile extends StatelessWidget {
const CakePayTile({
Key? key,
required this.title,
required this.subTitle,
this.onTap,
}) : super(key: key);
final VoidCallback? onTap;
final String title;
final String subTitle;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return GestureDetector(
onTap: onTap,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
SizedBox(height: 8),
Text(
subTitle,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: colorScheme.onSurface,
fontWeight: FontWeight.w600,
),
),
],
)
],
),
);
}
}
+82
View File
@@ -0,0 +1,82 @@
import 'package:flutter/material.dart';
import 'image_placeholder.dart';
class CardItem extends StatelessWidget {
CardItem({
required this.title,
required this.subTitle,
this.isAmount = false,
this.onTap,
this.logoUrl,
});
final VoidCallback? onTap;
final String title;
final String subTitle;
final String? logoUrl;
final bool isAmount;
@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(10),
),
child: Row(
children: [
if (logoUrl != null)
AspectRatio(
aspectRatio: 1.8,
child: ClipRRect(
borderRadius: BorderRadius.all(Radius.circular(10)),
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(),
),
),
),
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
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,28 @@
import 'package:flutter/material.dart';
class CakePayCardImagePlaceholder extends StatelessWidget {
const CakePayCardImagePlaceholder({this.text});
final String? text;
@override
Widget build(BuildContext context) {
return AspectRatio(
aspectRatio: 1.8,
child: Container(
child: Center(
child: Text(
text ?? 'Image not found!',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurface,
fontWeight: FontWeight.w900,
),
),
),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainer,
),
),
);
}
}
@@ -0,0 +1,65 @@
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';
class ClickableLinksText extends StatelessWidget {
const ClickableLinksText({
required this.text,
required this.textStyle,
this.linkStyle,
});
final String text;
final TextStyle textStyle;
final TextStyle? linkStyle;
@override
Widget build(BuildContext context) {
List<InlineSpan> spans = [];
RegExp linkRegExp = RegExp(r'(https?://[^\s]+)');
Iterable<Match> matches = linkRegExp.allMatches(text);
int previousEnd = 0;
matches.forEach((match) {
if (match.start > previousEnd) {
spans.add(TextSpan(text: text.substring(previousEnd, match.start), style: textStyle));
}
String url = text.substring(match.start, match.end);
if (url.toLowerCase().endsWith('.md')) {
spans.add(
TextSpan(
text: url,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.primary,
fontSize: 18,
),
recognizer: TapGestureRecognizer()
..onTap = () async {
if (await canLaunchUrl(Uri.parse(url))) {
await launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication);
}
},
),
);
} else {
spans.add(
TextSpan(
text: url,
style: linkStyle,
recognizer: TapGestureRecognizer()
..onTap = () {
launchUrl(Uri.parse(url));
},
),
);
}
previousEnd = match.end;
});
if (previousEnd < text.length) {
spans.add(TextSpan(text: text.substring(previousEnd), style: textStyle));
}
return RichText(text: TextSpan(children: spans));
}
}
@@ -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,
],
),
),
);
}
}
@@ -0,0 +1,36 @@
import 'package:flutter/material.dart';
class TextIconButton extends StatelessWidget {
const TextIconButton({
Key? key,
required this.label,
this.onTap,
}) : super(key: key);
final String label;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
label,
style: TextStyle(
color: Theme.of(context).textTheme.titleLarge!.color,
fontSize: 16,
fontWeight: FontWeight.w900,
),
),
Icon(
Icons.chevron_right_rounded,
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)),
],
),
),
],
),
),
),
);
}
}