| 1 | import 'package:cake_wallet/core/amount_validator.dart'; |
| 2 | import 'package:cake_wallet/entities/contact_base.dart'; |
| 3 | import 'package:cake_wallet/src/screens/receive/widgets/currency_input_field.dart'; |
| 4 | import 'package:cake_wallet/routes.dart'; |
| 5 | import 'package:cake_wallet/src/widgets/alert_with_two_actions.dart'; |
| 6 | import 'package:cake_wallet/utils/show_bar.dart'; |
| 7 | import 'package:cake_wallet/utils/show_pop_up.dart'; |
| 8 | import 'package:cake_wallet/utils/payment_request.dart'; |
| 9 | import 'package:cw_core/currency.dart'; |
| 10 | import 'package:flutter/services.dart'; |
| 11 | import 'package:flutter/material.dart'; |
| 12 | import 'package:cake_wallet/generated/i18n.dart'; |
| 13 | import 'package:cake_wallet/src/widgets/address_text_field.dart'; |
| 14 | import 'package:cake_wallet/src/widgets/base_text_form_field.dart'; |
| 15 | import 'package:cake_wallet/src/screens/exchange/widgets/currency_picker.dart'; |
| 16 | |
| 17 | class ExchangeCard<T extends Currency> extends StatefulWidget { |
| 18 | ExchangeCard({ |
| 19 | Key? key, |
| 20 | required this.initialCurrency, |
| 21 | required this.initialAddress, |
| 22 | required this.initialWalletName, |
| 23 | required this.initialIsAmountEditable, |
| 24 | required this.isAmountEstimated, |
| 25 | required this.currencies, |
| 26 | required this.onCurrencySelected, |
| 27 | required this.fillColor, |
| 28 | this.imageArrow, |
| 29 | this.currencyValueValidator, |
| 30 | this.addressTextFieldValidator, |
| 31 | this.title = '', |
| 32 | this.initialIsAddressEditable = true, |
| 33 | this.hasRefundAddress = false, |
| 34 | this.isMoneroWallet = false, |
| 35 | this.currencyButtonColor = Colors.transparent, |
| 36 | this.addressButtonsColor = Colors.transparent, |
| 37 | this.borderColor = Colors.transparent, |
| 38 | this.hasAllAmount = false, |
| 39 | this.isAllAmountEnabled = false, |
| 40 | this.showAddressField = true, |
| 41 | this.showLimitsField = true, |
| 42 | this.amountFocusNode, |
| 43 | this.addressFocusNode, |
| 44 | this.allAmount, |
| 45 | this.currencyRowPadding, |
| 46 | this.addressRowPadding, |
| 47 | this.onPushPasteButton, |
| 48 | this.onPushAddressBookButton, |
| 49 | this.onDispose, |
| 50 | this.useSatoshis = false, |
| 51 | this.onTapCurrencyPicker, |
| 52 | required this.cardInstanceName, |
| 53 | }) : super(key: key); |
| 54 | |
| 55 | final List<T> currencies; |
| 56 | final Function(T) onCurrencySelected; |
| 57 | final String title; |
| 58 | final T initialCurrency; |
| 59 | final String initialWalletName; |
| 60 | final String initialAddress; |
| 61 | final bool initialIsAmountEditable; |
| 62 | final bool initialIsAddressEditable; |
| 63 | final bool isAmountEstimated; |
| 64 | final bool hasRefundAddress; |
| 65 | final bool isMoneroWallet; |
| 66 | final Image? imageArrow; |
| 67 | final Color currencyButtonColor; |
| 68 | final Color? addressButtonsColor; |
| 69 | final Color borderColor; |
| 70 | final FormFieldValidator<String>? currencyValueValidator; |
| 71 | final FormFieldValidator<String>? addressTextFieldValidator; |
| 72 | final FormFieldValidator<String> allAmountValidator = AllAmountValidator(); |
| 73 | final FocusNode? amountFocusNode; |
| 74 | final FocusNode? addressFocusNode; |
| 75 | final bool hasAllAmount; |
| 76 | final bool showAddressField; |
| 77 | final bool showLimitsField; |
| 78 | final bool isAllAmountEnabled; |
| 79 | final VoidCallback? allAmount; |
| 80 | final EdgeInsets? currencyRowPadding; |
| 81 | final EdgeInsets? addressRowPadding; |
| 82 | final void Function(BuildContext context)? onPushPasteButton; |
| 83 | final void Function(BuildContext context)? onPushAddressBookButton; |
| 84 | final Function()? onDispose; |
| 85 | final String cardInstanceName; |
| 86 | final Color fillColor; |
| 87 | final bool useSatoshis; |
| 88 | final void Function(BuildContext context)? onTapCurrencyPicker; |
| 89 | |
| 90 | @override |
| 91 | ExchangeCardState<T> createState() => ExchangeCardState<T>(); |
| 92 | } |
| 93 | |
| 94 | class ExchangeCardState<T extends Currency> extends State<ExchangeCard<T>> { |
| 95 | ExchangeCardState() |
| 96 | : _title = '', |
| 97 | _min = '', |
| 98 | _max = '', |
| 99 | _isAmountEditable = false, |
| 100 | _isAddressEditable = false, |
| 101 | _walletName = '', |
| 102 | _isAmountEstimated = false, |
| 103 | _isMoneroWallet = false, |
| 104 | _cardInstanceName = ''; |
| 105 | |
| 106 | final addressController = TextEditingController(); |
| 107 | final amountController = TextEditingController(); |
| 108 | |
| 109 | String _cardInstanceName; |
| 110 | String _title; |
| 111 | String? _min; |
| 112 | String? _max; |
| 113 | late T _selectedCurrency; |
| 114 | String _walletName; |
| 115 | bool _isAmountEditable; |
| 116 | bool _isAddressEditable; |
| 117 | bool _isAmountEstimated; |
| 118 | bool _isMoneroWallet; |
| 119 | |
| 120 | @override |
| 121 | void initState() { |
| 122 | _cardInstanceName = widget.cardInstanceName; |
| 123 | _title = widget.title; |
| 124 | _isAmountEditable = widget.initialIsAmountEditable; |
| 125 | _isAddressEditable = widget.initialIsAddressEditable; |
| 126 | _walletName = widget.initialWalletName; |
| 127 | _selectedCurrency = widget.initialCurrency; |
| 128 | _isAmountEstimated = widget.isAmountEstimated; |
| 129 | _isMoneroWallet = widget.isMoneroWallet; |
| 130 | addressController.text = _normalizeAddressFormat(widget.initialAddress); |
| 131 | |
| 132 | super.initState(); |
| 133 | } |
| 134 | |
| 135 | @override |
| 136 | void dispose() { |
| 137 | widget.onDispose?.call(); |
| 138 | |
| 139 | super.dispose(); |
| 140 | } |
| 141 | |
| 142 | void changeLimits({String? min, String? max}) { |
| 143 | setState(() { |
| 144 | _min = min; |
| 145 | _max = max; |
| 146 | }); |
| 147 | } |
| 148 | |
| 149 | void changeSelectedCurrency(T currency) { |
| 150 | setState(() => _selectedCurrency = currency); |
| 151 | } |
| 152 | |
| 153 | void changeWalletName(String walletName) { |
| 154 | setState(() => _walletName = walletName); |
| 155 | } |
| 156 | |
| 157 | void changeIsAction(bool isActive) { |
| 158 | setState(() => _isAmountEditable = isActive); |
| 159 | } |
| 160 | |
| 161 | void isAmountEditable({bool isEditable = true}) { |
| 162 | setState(() => _isAmountEditable = isEditable); |
| 163 | } |
| 164 | |
| 165 | void isAddressEditable({bool isEditable = true}) { |
| 166 | setState(() => _isAddressEditable = isEditable); |
| 167 | } |
| 168 | |
| 169 | void changeAddress({required String address}) { |
| 170 | setState(() => addressController.text = _normalizeAddressFormat(address)); |
| 171 | } |
| 172 | |
| 173 | void changeAmount({required String amount}) { |
| 174 | setState(() => amountController.text = amount); |
| 175 | } |
| 176 | |
| 177 | void changeIsAmountEstimated(bool isEstimated) { |
| 178 | setState(() => _isAmountEstimated = isEstimated); |
| 179 | } |
| 180 | |
| 181 | @override |
| 182 | Widget build(BuildContext context) { |
| 183 | if (widget.isAllAmountEnabled) { |
| 184 | WidgetsBinding.instance.addPostFrameCallback((_) { |
| 185 | amountController.text = S.of(context).all; |
| 186 | }); |
| 187 | } |
| 188 | |
| 189 | final copyImage = Image.asset( |
| 190 | 'assets/images/copy_content.png', |
| 191 | height: 16, |
| 192 | width: 16, |
| 193 | color: Theme.of(context).colorScheme.onSurface, |
| 194 | ); |
| 195 | |
| 196 | return Container( |
| 197 | width: double.infinity, |
| 198 | color: Colors.transparent, |
| 199 | child: Column( |
| 200 | crossAxisAlignment: CrossAxisAlignment.start, |
| 201 | children: <Widget>[ |
| 202 | SizedBox(height: 10), |
| 203 | Row( |
| 204 | mainAxisAlignment: MainAxisAlignment.start, |
| 205 | children: <Widget>[ |
| 206 | SizedBox(height: 40), |
| 207 | Text( |
| 208 | key: ValueKey('${_cardInstanceName}_title_key'), |
| 209 | _title, |
| 210 | style: Theme.of(context).textTheme.titleLarge!.copyWith( |
| 211 | fontSize: 18, |
| 212 | fontWeight: FontWeight.w600, |
| 213 | color: Theme.of(context).colorScheme.onSurfaceVariant, |
| 214 | ), |
| 215 | ) |
| 216 | ], |
| 217 | ), |
| 218 | CurrencyAmountTextField( |
| 219 | hasUnderlineBorder: true, |
| 220 | borderWidth: 0.0, |
| 221 | padding: EdgeInsets.zero, |
| 222 | currencyPickerButtonKey: ValueKey('${_cardInstanceName}_currency_picker_button_key'), |
| 223 | selectedCurrencyTextKey: ValueKey('${_cardInstanceName}_selected_currency_text_key'), |
| 224 | selectedCurrencyTagTextKey: |
| 225 | ValueKey('${_cardInstanceName}_selected_currency_tag_text_key'), |
| 226 | amountTextfieldKey: ValueKey('${_cardInstanceName}_amount_textfield_key'), |
| 227 | sendAllButtonKey: ValueKey('${_cardInstanceName}_send_all_button_key'), |
| 228 | currencyAmountTextFieldWidgetKey: |
| 229 | ValueKey('${_cardInstanceName}_currency_amount_textfield_widget_key'), |
| 230 | imageArrow: widget.imageArrow, |
| 231 | selectedCurrency: widget.useSatoshis ? "SATS" : "$_selectedCurrency", |
| 232 | selectedCurrencyDecimals: widget.useSatoshis ? 0 : _selectedCurrency.decimals, |
| 233 | amountFocusNode: widget.amountFocusNode, |
| 234 | amountController: amountController, |
| 235 | onTapPicker: () => _presentPicker(context), |
| 236 | isAmountEditable: _isAmountEditable, |
| 237 | isPickerEnable: true, |
| 238 | allAmountButton: widget.hasAllAmount, |
| 239 | currencyValueValidator: widget.currencyValueValidator, |
| 240 | tag: _selectedCurrency.tag, |
| 241 | allAmountCallback: widget.allAmount, |
| 242 | fillColor: widget.fillColor, |
| 243 | ), |
| 244 | Divider(height: 1, color: Theme.of(context).colorScheme.outlineVariant), |
| 245 | Padding( |
| 246 | padding: EdgeInsets.only(top: 5), |
| 247 | child: widget.showLimitsField |
| 248 | ? Container( |
| 249 | height: 15, |
| 250 | child: Row( |
| 251 | mainAxisAlignment: MainAxisAlignment.start, |
| 252 | children: <Widget>[ |
| 253 | _min != null |
| 254 | ? Text( |
| 255 | key: ValueKey('${_cardInstanceName}_min_limit_text_key'), |
| 256 | S.of(context).min_value(_min ?? '', _selectedCurrency.toString()), |
| 257 | style: Theme.of(context).textTheme.bodySmall!.copyWith( |
| 258 | fontSize: 10, |
| 259 | height: 1.2, |
| 260 | color: Theme.of(context).colorScheme.onSurfaceVariant, |
| 261 | ), |
| 262 | ) |
| 263 | : Offstage(), |
| 264 | _min != null ? SizedBox(width: 10) : Offstage(), |
| 265 | _max != null |
| 266 | ? Text( |
| 267 | key: ValueKey('${_cardInstanceName}_max_limit_text_key'), |
| 268 | S.of(context).max_value(_max ?? '', _selectedCurrency.toString()), |
| 269 | style: Theme.of(context).textTheme.bodySmall!.copyWith( |
| 270 | fontSize: 10, |
| 271 | height: 1.2, |
| 272 | color: Theme.of(context).colorScheme.onSurfaceVariant, |
| 273 | ), |
| 274 | ) |
| 275 | : Offstage(), |
| 276 | ], |
| 277 | ), |
| 278 | ) |
| 279 | : Offstage(), |
| 280 | ), |
| 281 | !_isAddressEditable && widget.hasRefundAddress |
| 282 | ? Padding( |
| 283 | padding: EdgeInsets.only(top: 20), |
| 284 | child: Text( |
| 285 | S.of(context).refund_address, |
| 286 | style: Theme.of(context).textTheme.bodyMedium!.copyWith( |
| 287 | fontWeight: FontWeight.w500, |
| 288 | color: Theme.of(context).colorScheme.onSurfaceVariant, |
| 289 | ), |
| 290 | ), |
| 291 | ) |
| 292 | : Offstage(), |
| 293 | _isAddressEditable |
| 294 | ? widget.showAddressField |
| 295 | ? FocusTraversalOrder( |
| 296 | order: NumericFocusOrder(2), |
| 297 | child: Padding( |
| 298 | padding: widget.addressRowPadding ?? EdgeInsets.only(top: 12), |
| 299 | child: AddressTextField( |
| 300 | hasUnderlineBorder: true, |
| 301 | borderWidth: 0.0, |
| 302 | addressKey: |
| 303 | ValueKey('${_cardInstanceName}_editable_address_textfield_key'), |
| 304 | focusNode: widget.addressFocusNode, |
| 305 | controller: addressController, |
| 306 | onURIScanned: (uri) { |
| 307 | final paymentRequest = PaymentRequest.fromUri(uri); |
| 308 | addressController.text = paymentRequest.address; |
| 309 | |
| 310 | if (amountController.text.isNotEmpty && |
| 311 | paymentRequest.amount.isNotEmpty) { |
| 312 | _showAmountPopup(context, paymentRequest); |
| 313 | return; |
| 314 | } |
| 315 | widget.amountFocusNode?.requestFocus(); |
| 316 | amountController.text = paymentRequest.amount; |
| 317 | }, |
| 318 | placeholder: |
| 319 | widget.hasRefundAddress ? S.of(context).refund_address : null, |
| 320 | options: [ |
| 321 | AddressTextFieldOption.paste, |
| 322 | AddressTextFieldOption.qrCode, |
| 323 | AddressTextFieldOption.addressBook, |
| 324 | ], |
| 325 | textStyle: Theme.of(context).textTheme.bodyMedium!.copyWith( |
| 326 | fontSize: 16, |
| 327 | fontWeight: FontWeight.w600, |
| 328 | color: Theme.of(context).colorScheme.onSurface, |
| 329 | ), |
| 330 | hintStyle: Theme.of(context).textTheme.bodyMedium!.copyWith( |
| 331 | fontSize: 16, |
| 332 | fontWeight: FontWeight.w600, |
| 333 | color: Theme.of(context).colorScheme.onSurfaceVariant, |
| 334 | ), |
| 335 | buttonColor: widget.addressButtonsColor, |
| 336 | validator: widget.addressTextFieldValidator, |
| 337 | onPushPasteButton: widget.onPushPasteButton, |
| 338 | onPushAddressBookButton: widget.onPushAddressBookButton, |
| 339 | selectedCurrency: _selectedCurrency, |
| 340 | fillColor: widget.fillColor, |
| 341 | iconColor: Theme.of(context).colorScheme.onSurfaceVariant, |
| 342 | ), |
| 343 | ), |
| 344 | ) |
| 345 | : Offstage() |
| 346 | : Padding( |
| 347 | padding: EdgeInsets.only(top: 0), |
| 348 | child: Builder( |
| 349 | builder: (context) => Stack( |
| 350 | children: <Widget>[ |
| 351 | FocusTraversalOrder( |
| 352 | order: NumericFocusOrder(3), |
| 353 | child: BaseTextFormField( |
| 354 | hasUnderlineBorder: true, |
| 355 | borderWidth: 0.0, |
| 356 | key: |
| 357 | ValueKey('${_cardInstanceName}_non_editable_address_textfield_key'), |
| 358 | controller: addressController, |
| 359 | suffixIcon: SizedBox(width: _isMoneroWallet ? 80 : 36), |
| 360 | textStyle: Theme.of(context).textTheme.bodyMedium!.copyWith( |
| 361 | fontSize: 16, |
| 362 | fontWeight: FontWeight.w600, |
| 363 | ), |
| 364 | validator: widget.addressTextFieldValidator, |
| 365 | fillColor: widget.fillColor, |
| 366 | ), |
| 367 | ), |
| 368 | Positioned( |
| 369 | top: 2, |
| 370 | right: 0, |
| 371 | child: SizedBox( |
| 372 | width: _isMoneroWallet ? 80 : 36, |
| 373 | child: Row( |
| 374 | children: <Widget>[ |
| 375 | if (_isMoneroWallet) |
| 376 | Padding( |
| 377 | padding: EdgeInsets.only(left: 10), |
| 378 | child: Container( |
| 379 | width: 34, |
| 380 | height: 34, |
| 381 | padding: EdgeInsets.only(top: 0), |
| 382 | child: Semantics( |
| 383 | label: S.of(context).address_book, |
| 384 | child: InkWell( |
| 385 | key: ValueKey( |
| 386 | '${_cardInstanceName}_address_book_button_key'), |
| 387 | onTap: () async { |
| 388 | final contact = await Navigator.of(context).pushNamed( |
| 389 | Routes.pickerAddressBook, |
| 390 | arguments: [widget.initialCurrency, true], |
| 391 | ); |
| 392 | |
| 393 | if (contact is ContactBase) { |
| 394 | setState( |
| 395 | () => addressController.text = contact.address); |
| 396 | widget.onPushAddressBookButton?.call(context); |
| 397 | } |
| 398 | }, |
| 399 | child: Container( |
| 400 | padding: EdgeInsets.all(8), |
| 401 | decoration: BoxDecoration( |
| 402 | color: widget.addressButtonsColor, |
| 403 | borderRadius: BorderRadius.all(Radius.circular(6))), |
| 404 | child: Image.asset( |
| 405 | 'assets/images/open_book.png', |
| 406 | color: Theme.of(context).colorScheme.onSurface, |
| 407 | ), |
| 408 | ), |
| 409 | ), |
| 410 | ), |
| 411 | ), |
| 412 | ), |
| 413 | Padding( |
| 414 | padding: EdgeInsets.only(left: 2), |
| 415 | child: Container( |
| 416 | width: 34, |
| 417 | height: 34, |
| 418 | padding: EdgeInsets.only(top: 0), |
| 419 | child: Semantics( |
| 420 | label: S.of(context).copy_address, |
| 421 | child: InkWell( |
| 422 | key: ValueKey( |
| 423 | '${_cardInstanceName}_copy_refund_address_button_key'), |
| 424 | onTap: () { |
| 425 | Clipboard.setData( |
| 426 | ClipboardData(text: addressController.text)); |
| 427 | showBar<void>(context, S.of(context).copied_to_clipboard); |
| 428 | }, |
| 429 | child: Container( |
| 430 | padding: EdgeInsets.fromLTRB(8, 8, 0, 8), |
| 431 | color: Colors.transparent, |
| 432 | child: copyImage, |
| 433 | ), |
| 434 | ), |
| 435 | ), |
| 436 | ), |
| 437 | ) |
| 438 | ], |
| 439 | ), |
| 440 | ), |
| 441 | ) |
| 442 | ], |
| 443 | ), |
| 444 | ), |
| 445 | ), |
| 446 | ], |
| 447 | ), |
| 448 | ); |
| 449 | } |
| 450 | |
| 451 | void _presentPicker(BuildContext context) { |
| 452 | if (widget.onTapCurrencyPicker != null) { |
| 453 | widget.onTapCurrencyPicker!(context); |
| 454 | return; |
| 455 | } |
| 456 | showPopUp<void>( |
| 457 | context: context, |
| 458 | builder: (_) => CurrencyPicker( |
| 459 | key: ValueKey('${_cardInstanceName}_currency_picker_dialog_button_key'), |
| 460 | selectedAtIndex: widget.currencies.indexOf(_selectedCurrency), |
| 461 | items: widget.currencies, |
| 462 | hintText: S.of(context).search_currency, |
| 463 | isConvertFrom: widget.hasRefundAddress, |
| 464 | onItemSelected: (Currency item) => widget.onCurrencySelected(item as T), |
| 465 | ), |
| 466 | ); |
| 467 | } |
| 468 | |
| 469 | void _showAmountPopup(BuildContext context, PaymentRequest paymentRequest) { |
| 470 | showPopUp<void>( |
| 471 | context: context, |
| 472 | builder: (dialogContext) { |
| 473 | return AlertWithTwoActions( |
| 474 | alertTitle: S.of(dialogContext).overwrite_amount, |
| 475 | alertContent: S.of(dialogContext).qr_payment_amount, |
| 476 | rightButtonText: S.of(dialogContext).ok, |
| 477 | leftButtonText: S.of(dialogContext).cancel, |
| 478 | actionRightButton: () { |
| 479 | widget.amountFocusNode?.requestFocus(); |
| 480 | amountController.text = paymentRequest.amount; |
| 481 | Navigator.of(dialogContext).pop(); |
| 482 | }, |
| 483 | actionLeftButton: () => Navigator.of(dialogContext).pop()); |
| 484 | }); |
| 485 | } |
| 486 | |
| 487 | String _normalizeAddressFormat(String address) { |
| 488 | if (address.startsWith('bitcoincash:')) address = address.substring(12); |
| 489 | return address; |
| 490 | } |
| 491 | } |