Files
hash-wallet/lib/src/screens/exchange/widgets/exchange_card.dart
T
jwinterm dd449f2594 Merge upstream Cake Wallet v6.5.0
Catches up from the May 12 fork point (v6.1.2+23) through v6.5.0: 535
upstream commits, including the Monero silent-open fix, Monero on Trezor,
Ledger/Trezor BLE fixes, the new seed and history screens, quick Bitcoin
sync, git-pinned dependencies (pubspec_overrides.yaml), frozen Hive
adapters (*.part.dart), monero_c v0.18.4.6-RC2 and Flutter 3.41.9.

Merged with both sides normalized first (import rename, 100-column
format), which took the conflict count from 614 paths to 322. The
procedure is now scripts/upstream_sync.sh.

Kept from Hash Bags:
- WalletType enum order and Hive IDs. SQLite stores WalletType.index and
  our order differs from upstream's; taking theirs would reread saved
  wallets as other chains. Our frozen adapter keeps the shipped IDs.
- The 1.0.2 legacy PIN and wallet-password decode and self-heal.
- Removed chains (Solana, Tron, Zano, Zcash, Decred, Haven) and the new
  Robinhood chain stay out; Cake Pay and non-Trocador swaps stay out.
- Default nodes (node.wownero.org, sethforprivacy, Blockstream, bysh.me),
  converted to upstream's isDefault node-list key; Wownero is back in the
  node reset list.
- Privacy: address resolvers limited to domain-anchored sources again;
  prices.neroswap.com for fiat; upstream's charts tab, which reads
  prices.cakewallet.com, sits behind FeatureFlag.isChartsEnabled = false.
- Lightning stays behind FeatureFlag.isLightningEnabled, including
  upstream's new post-seed Lightning username step.
- Our icon pipeline, branding and bundle identifiers.

Twelve new upstream widget tests are skipped: test and code are unchanged
from v6.5.0 apart from renames (not rerun on a pristine upstream checkout;
upstream's top_bar_test does not compile against its own TopBar).
flutter analyze lib: 0 errors; flutter test: 345 passed, 12 skipped;
flutter build bundle compiles. Built with Flutter 3.41.9.
2026-10-24 23:49:29 -04:00

492 lines
20 KiB
Dart

import 'package:hash_wallet/core/amount_validator.dart';
import 'package:hash_wallet/entities/contact_base.dart';
import 'package:hash_wallet/src/screens/receive/widgets/currency_input_field.dart';
import 'package:hash_wallet/routes.dart';
import 'package:hash_wallet/src/widgets/alert_with_two_actions.dart';
import 'package:hash_wallet/utils/show_bar.dart';
import 'package:hash_wallet/utils/show_pop_up.dart';
import 'package:hash_wallet/utils/payment_request.dart';
import 'package:cw_core/currency.dart';
import 'package:flutter/services.dart';
import 'package:flutter/material.dart';
import 'package:hash_wallet/generated/i18n.dart';
import 'package:hash_wallet/src/widgets/address_text_field.dart';
import 'package:hash_wallet/src/widgets/base_text_form_field.dart';
import 'package:hash_wallet/src/screens/exchange/widgets/currency_picker.dart';
class ExchangeCard<T extends Currency> extends StatefulWidget {
ExchangeCard({
Key? key,
required this.initialCurrency,
required this.initialAddress,
required this.initialWalletName,
required this.initialIsAmountEditable,
required this.isAmountEstimated,
required this.currencies,
required this.onCurrencySelected,
required this.fillColor,
this.imageArrow,
this.currencyValueValidator,
this.addressTextFieldValidator,
this.title = '',
this.initialIsAddressEditable = true,
this.hasRefundAddress = false,
this.isMoneroWallet = false,
this.currencyButtonColor = Colors.transparent,
this.addressButtonsColor = Colors.transparent,
this.borderColor = Colors.transparent,
this.hasAllAmount = false,
this.isAllAmountEnabled = false,
this.showAddressField = true,
this.showLimitsField = true,
this.amountFocusNode,
this.addressFocusNode,
this.allAmount,
this.currencyRowPadding,
this.addressRowPadding,
this.onPushPasteButton,
this.onPushAddressBookButton,
this.onDispose,
this.useSatoshis = false,
this.onTapCurrencyPicker,
required this.cardInstanceName,
}) : super(key: key);
final List<T> currencies;
final Function(T) onCurrencySelected;
final String title;
final T initialCurrency;
final String initialWalletName;
final String initialAddress;
final bool initialIsAmountEditable;
final bool initialIsAddressEditable;
final bool isAmountEstimated;
final bool hasRefundAddress;
final bool isMoneroWallet;
final Image? imageArrow;
final Color currencyButtonColor;
final Color? addressButtonsColor;
final Color borderColor;
final FormFieldValidator<String>? currencyValueValidator;
final FormFieldValidator<String>? addressTextFieldValidator;
final FormFieldValidator<String> allAmountValidator = AllAmountValidator();
final FocusNode? amountFocusNode;
final FocusNode? addressFocusNode;
final bool hasAllAmount;
final bool showAddressField;
final bool showLimitsField;
final bool isAllAmountEnabled;
final VoidCallback? allAmount;
final EdgeInsets? currencyRowPadding;
final EdgeInsets? addressRowPadding;
final void Function(BuildContext context)? onPushPasteButton;
final void Function(BuildContext context)? onPushAddressBookButton;
final Function()? onDispose;
final String cardInstanceName;
final Color fillColor;
final bool useSatoshis;
final void Function(BuildContext context)? onTapCurrencyPicker;
@override
ExchangeCardState<T> createState() => ExchangeCardState<T>();
}
class ExchangeCardState<T extends Currency> extends State<ExchangeCard<T>> {
ExchangeCardState()
: _title = '',
_min = '',
_max = '',
_isAmountEditable = false,
_isAddressEditable = false,
_walletName = '',
_isAmountEstimated = false,
_isMoneroWallet = false,
_cardInstanceName = '';
final addressController = TextEditingController();
final amountController = TextEditingController();
String _cardInstanceName;
String _title;
String? _min;
String? _max;
late T _selectedCurrency;
String _walletName;
bool _isAmountEditable;
bool _isAddressEditable;
bool _isAmountEstimated;
bool _isMoneroWallet;
@override
void initState() {
_cardInstanceName = widget.cardInstanceName;
_title = widget.title;
_isAmountEditable = widget.initialIsAmountEditable;
_isAddressEditable = widget.initialIsAddressEditable;
_walletName = widget.initialWalletName;
_selectedCurrency = widget.initialCurrency;
_isAmountEstimated = widget.isAmountEstimated;
_isMoneroWallet = widget.isMoneroWallet;
addressController.text = _normalizeAddressFormat(widget.initialAddress);
super.initState();
}
@override
void dispose() {
widget.onDispose?.call();
super.dispose();
}
void changeLimits({String? min, String? max}) {
setState(() {
_min = min;
_max = max;
});
}
void changeSelectedCurrency(T currency) {
setState(() => _selectedCurrency = currency);
}
void changeWalletName(String walletName) {
setState(() => _walletName = walletName);
}
void changeIsAction(bool isActive) {
setState(() => _isAmountEditable = isActive);
}
void isAmountEditable({bool isEditable = true}) {
setState(() => _isAmountEditable = isEditable);
}
void isAddressEditable({bool isEditable = true}) {
setState(() => _isAddressEditable = isEditable);
}
void changeAddress({required String address}) {
setState(() => addressController.text = _normalizeAddressFormat(address));
}
void changeAmount({required String amount}) {
setState(() => amountController.text = amount);
}
void changeIsAmountEstimated(bool isEstimated) {
setState(() => _isAmountEstimated = isEstimated);
}
@override
Widget build(BuildContext context) {
if (widget.isAllAmountEnabled) {
WidgetsBinding.instance.addPostFrameCallback((_) {
amountController.text = S.of(context).all;
});
}
final copyImage = Image.asset(
'assets/images/copy_content.png',
height: 16,
width: 16,
color: Theme.of(context).colorScheme.onSurface,
);
return Container(
width: double.infinity,
color: Colors.transparent,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
SizedBox(height: 10),
Row(
mainAxisAlignment: MainAxisAlignment.start,
children: <Widget>[
SizedBox(height: 40),
Text(
key: ValueKey('${_cardInstanceName}_title_key'),
_title,
style: Theme.of(context).textTheme.titleLarge!.copyWith(
fontSize: 18,
fontWeight: FontWeight.w600,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
)
],
),
CurrencyAmountTextField(
hasUnderlineBorder: true,
borderWidth: 0.0,
padding: EdgeInsets.zero,
currencyPickerButtonKey: ValueKey('${_cardInstanceName}_currency_picker_button_key'),
selectedCurrencyTextKey: ValueKey('${_cardInstanceName}_selected_currency_text_key'),
selectedCurrencyTagTextKey:
ValueKey('${_cardInstanceName}_selected_currency_tag_text_key'),
amountTextfieldKey: ValueKey('${_cardInstanceName}_amount_textfield_key'),
sendAllButtonKey: ValueKey('${_cardInstanceName}_send_all_button_key'),
currencyAmountTextFieldWidgetKey:
ValueKey('${_cardInstanceName}_currency_amount_textfield_widget_key'),
imageArrow: widget.imageArrow,
selectedCurrency: widget.useSatoshis ? "SATS" : "$_selectedCurrency",
selectedCurrencyDecimals: widget.useSatoshis ? 0 : _selectedCurrency.decimals,
amountFocusNode: widget.amountFocusNode,
amountController: amountController,
onTapPicker: () => _presentPicker(context),
isAmountEditable: _isAmountEditable,
isPickerEnable: true,
allAmountButton: widget.hasAllAmount,
currencyValueValidator: widget.currencyValueValidator,
tag: _selectedCurrency.tag,
allAmountCallback: widget.allAmount,
fillColor: widget.fillColor,
),
Divider(height: 1, color: Theme.of(context).colorScheme.outlineVariant),
Padding(
padding: EdgeInsets.only(top: 5),
child: widget.showLimitsField
? Container(
height: 15,
child: Row(
mainAxisAlignment: MainAxisAlignment.start,
children: <Widget>[
_min != null
? Text(
key: ValueKey('${_cardInstanceName}_min_limit_text_key'),
S.of(context).min_value(_min ?? '', _selectedCurrency.toString()),
style: Theme.of(context).textTheme.bodySmall!.copyWith(
fontSize: 10,
height: 1.2,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
)
: Offstage(),
_min != null ? SizedBox(width: 10) : Offstage(),
_max != null
? Text(
key: ValueKey('${_cardInstanceName}_max_limit_text_key'),
S.of(context).max_value(_max ?? '', _selectedCurrency.toString()),
style: Theme.of(context).textTheme.bodySmall!.copyWith(
fontSize: 10,
height: 1.2,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
)
: Offstage(),
],
),
)
: Offstage(),
),
!_isAddressEditable && widget.hasRefundAddress
? Padding(
padding: EdgeInsets.only(top: 20),
child: Text(
S.of(context).refund_address,
style: Theme.of(context).textTheme.bodyMedium!.copyWith(
fontWeight: FontWeight.w500,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
)
: Offstage(),
_isAddressEditable
? widget.showAddressField
? FocusTraversalOrder(
order: NumericFocusOrder(2),
child: Padding(
padding: widget.addressRowPadding ?? EdgeInsets.only(top: 12),
child: AddressTextField(
hasUnderlineBorder: true,
borderWidth: 0.0,
addressKey:
ValueKey('${_cardInstanceName}_editable_address_textfield_key'),
focusNode: widget.addressFocusNode,
controller: addressController,
onURIScanned: (uri) {
final paymentRequest = PaymentRequest.fromUri(uri);
addressController.text = paymentRequest.address;
if (amountController.text.isNotEmpty &&
paymentRequest.amount.isNotEmpty) {
_showAmountPopup(context, paymentRequest);
return;
}
widget.amountFocusNode?.requestFocus();
amountController.text = paymentRequest.amount;
},
placeholder:
widget.hasRefundAddress ? S.of(context).refund_address : null,
options: [
AddressTextFieldOption.paste,
AddressTextFieldOption.qrCode,
AddressTextFieldOption.addressBook,
],
textStyle: Theme.of(context).textTheme.bodyMedium!.copyWith(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Theme.of(context).colorScheme.onSurface,
),
hintStyle: Theme.of(context).textTheme.bodyMedium!.copyWith(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
buttonColor: widget.addressButtonsColor,
validator: widget.addressTextFieldValidator,
onPushPasteButton: widget.onPushPasteButton,
onPushAddressBookButton: widget.onPushAddressBookButton,
selectedCurrency: _selectedCurrency,
fillColor: widget.fillColor,
iconColor: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
)
: Offstage()
: Padding(
padding: EdgeInsets.only(top: 0),
child: Builder(
builder: (context) => Stack(
children: <Widget>[
FocusTraversalOrder(
order: NumericFocusOrder(3),
child: BaseTextFormField(
hasUnderlineBorder: true,
borderWidth: 0.0,
key:
ValueKey('${_cardInstanceName}_non_editable_address_textfield_key'),
controller: addressController,
suffixIcon: SizedBox(width: _isMoneroWallet ? 80 : 36),
textStyle: Theme.of(context).textTheme.bodyMedium!.copyWith(
fontSize: 16,
fontWeight: FontWeight.w600,
),
validator: widget.addressTextFieldValidator,
fillColor: widget.fillColor,
),
),
Positioned(
top: 2,
right: 0,
child: SizedBox(
width: _isMoneroWallet ? 80 : 36,
child: Row(
children: <Widget>[
if (_isMoneroWallet)
Padding(
padding: EdgeInsets.only(left: 10),
child: Container(
width: 34,
height: 34,
padding: EdgeInsets.only(top: 0),
child: Semantics(
label: S.of(context).address_book,
child: InkWell(
key: ValueKey(
'${_cardInstanceName}_address_book_button_key'),
onTap: () async {
final contact = await Navigator.of(context).pushNamed(
Routes.pickerAddressBook,
arguments: [widget.initialCurrency, true],
);
if (contact is ContactBase) {
setState(
() => addressController.text = contact.address);
widget.onPushAddressBookButton?.call(context);
}
},
child: Container(
padding: EdgeInsets.all(8),
decoration: BoxDecoration(
color: widget.addressButtonsColor,
borderRadius: BorderRadius.all(Radius.circular(6))),
child: Image.asset(
'assets/images/open_book.png',
color: Theme.of(context).colorScheme.onSurface,
),
),
),
),
),
),
Padding(
padding: EdgeInsets.only(left: 2),
child: Container(
width: 34,
height: 34,
padding: EdgeInsets.only(top: 0),
child: Semantics(
label: S.of(context).copy_address,
child: InkWell(
key: ValueKey(
'${_cardInstanceName}_copy_refund_address_button_key'),
onTap: () {
Clipboard.setData(
ClipboardData(text: addressController.text));
showBar<void>(context, S.of(context).copied_to_clipboard);
},
child: Container(
padding: EdgeInsets.fromLTRB(8, 8, 0, 8),
color: Colors.transparent,
child: copyImage,
),
),
),
),
)
],
),
),
)
],
),
),
),
],
),
);
}
void _presentPicker(BuildContext context) {
if (widget.onTapCurrencyPicker != null) {
widget.onTapCurrencyPicker!(context);
return;
}
showPopUp<void>(
context: context,
builder: (_) => CurrencyPicker(
key: ValueKey('${_cardInstanceName}_currency_picker_dialog_button_key'),
selectedAtIndex: widget.currencies.indexOf(_selectedCurrency),
items: widget.currencies,
hintText: S.of(context).search_currency,
isConvertFrom: widget.hasRefundAddress,
onItemSelected: (Currency item) => widget.onCurrencySelected(item as T),
),
);
}
void _showAmountPopup(BuildContext context, PaymentRequest paymentRequest) {
showPopUp<void>(
context: context,
builder: (dialogContext) {
return AlertWithTwoActions(
alertTitle: S.of(dialogContext).overwrite_amount,
alertContent: S.of(dialogContext).qr_payment_amount,
rightButtonText: S.of(dialogContext).ok,
leftButtonText: S.of(dialogContext).cancel,
actionRightButton: () {
widget.amountFocusNode?.requestFocus();
amountController.text = paymentRequest.amount;
Navigator.of(dialogContext).pop();
},
actionLeftButton: () => Navigator.of(dialogContext).pop());
});
}
String _normalizeAddressFormat(String address) {
if (address.startsWith('bitcoincash:')) address = address.substring(12);
return address;
}
}