mirror of
https://github.com/Such-Software/hash-wallet.git
synced 2026-10-21 06:18:42 +02:00
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:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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'<[^>]*>|&[^;]+;'), ' ');
|
||||
}
|
||||
}
|
||||
@@ -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'<[^>]*>|&[^;]+;'), ' ');
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user