diff --git a/android/build.gradle b/android/build.gradle index d4da024..e13d87c 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -23,6 +23,19 @@ rootProject.buildDir = '../build' subprojects { project.buildDir = "${rootProject.buildDir}/${project.name}" } + +subprojects { + afterEvaluate { project -> + if (project.plugins.hasPlugin("com.android.application") || + project.plugins.hasPlugin("com.android.library")) { + project.android { + compileSdkVersion 34 + buildToolsVersion "34.0.0" + } + } + } +} + subprojects { project.evaluationDependsOn(':app') } diff --git a/lib/contacts/contact_add_sheet.dart b/lib/contacts/contact_add_sheet.dart index bbdd9b8..b51002b 100644 --- a/lib/contacts/contact_add_sheet.dart +++ b/lib/contacts/contact_add_sheet.dart @@ -14,7 +14,6 @@ import '../widgets/address_widgets.dart'; import '../widgets/app_text_field.dart'; import '../widgets/buttons.dart'; import '../widgets/sheet_widget.dart'; -import '../widgets/tap_outside_unfocus.dart'; import 'contact.dart'; class ContactAddSheet extends ConsumerStatefulWidget { @@ -89,129 +88,106 @@ class _ContactAddSheetState extends ConsumerState { final addressPrefix = ref.watch(addressPrefixProvider); - return TapOutsideUnfocus( - child: SheetWidget( - title: l10n.addContact, - mainWidget: Column( - children: [ - // Enter Name Container - AppTextField( - topMargin: MediaQuery.of(context).size.height * 0.14, - padding: const EdgeInsets.symmetric(horizontal: 30), - focusNode: _nameFocusNode, - controller: _nameController, - textInputAction: widget.address != null - ? TextInputAction.done - : TextInputAction.next, - hintText: _showNameHint ? l10n.contactNameHint : "", - keyboardType: TextInputType.text, - style: styles.textStyleAppTextFieldSimple, - inputFormatters: [ - LengthLimitingTextInputFormatter(20), - ContactFormatter() - ], - onSubmitted: (text) { - final scope = FocusScope.of(context); - if (widget.address == null) { - final address = _addressController.text; - final prefix = ref.read(addressPrefixProvider); - if (!Address.isValid(address, prefix)) { - scope.requestFocus(_addressFocusNode); - } else { - scope.unfocus(); - } + return SheetWidget( + title: l10n.addContact, + mainWidget: Column( + children: [ + // Enter Name Container + AppTextField( + topMargin: MediaQuery.of(context).size.height * 0.14, + padding: const EdgeInsets.symmetric(horizontal: 30), + focusNode: _nameFocusNode, + controller: _nameController, + textInputAction: widget.address != null + ? TextInputAction.done + : TextInputAction.next, + hintText: _showNameHint ? l10n.contactNameHint : "", + keyboardType: TextInputType.text, + style: styles.textStyleAppTextFieldSimple, + inputFormatters: [ + LengthLimitingTextInputFormatter(20), + ContactFormatter() + ], + onSubmitted: (text) { + final scope = FocusScope.of(context); + if (widget.address == null) { + final address = _addressController.text; + final prefix = ref.read(addressPrefixProvider); + if (!Address.isValid(address, prefix)) { + scope.requestFocus(_addressFocusNode); } else { scope.unfocus(); } - }, - ), - // Enter Name Error Container - Container( - margin: const EdgeInsets.only(top: 5, bottom: 5), - child: Text( - _nameValidationText, - style: styles.textStyleParagraphThinPrimary, - ), + } else { + scope.unfocus(); + } + }, + ), + // Enter Name Error Container + Container( + margin: const EdgeInsets.only(top: 5, bottom: 5), + child: Text( + _nameValidationText, + style: styles.textStyleParagraphThinPrimary, ), - // Enter Address container - AppTextField( - padding: !_shouldShowTextField() - ? EdgeInsets.symmetric(horizontal: 25, vertical: 15) - : EdgeInsets.zero, - focusNode: _addressFocusNode, - controller: _addressController, - style: _addressValid - ? styles.textStyleAddressText90 - : styles.textStyleAddressText60, - inputFormatters: [ - LengthLimitingTextInputFormatter(74), - ], - textInputAction: TextInputAction.done, - maxLines: null, - autocorrect: false, - hintText: _showAddressHint ? l10n.addressHint : '', - prefixButton: TextFieldButton( - icon: AppIcons.scan, - onPressed: () async { - final scanResult = await UserDataUtil.scanQrCode(context); - final data = scanResult?.code; - if (data == null) { - UIUtil.showSnackbar(l10n.qrInvalidAddress, context); - } else { - final address = Address.tryParse( - data, - expectedPrefix: addressPrefix, - ); - if (mounted && address != null) { - setState(() { - _addressController.text = address.toString(); - _addressValidationText = ""; - _addressValid = true; - _addressValidAndUnfocused = true; - }); - _addressFocusNode.unfocus(); - } - } - }, - ), - fadePrefixOnCondition: true, - prefixShowFirstCondition: _showPasteButton, - suffixButton: TextFieldButton( - icon: AppIcons.paste, - onPressed: () async { - if (!_showPasteButton) { - return; - } - String? data = - await UserDataUtil.getClipboardText(DataType.ADDRESS); - if (data != null) { + ), + // Enter Address container + AppTextField( + padding: !_shouldShowTextField() + ? EdgeInsets.symmetric(horizontal: 25, vertical: 15) + : EdgeInsets.zero, + focusNode: _addressFocusNode, + controller: _addressController, + style: _addressValid + ? styles.textStyleAddressText90 + : styles.textStyleAddressText60, + inputFormatters: [ + LengthLimitingTextInputFormatter(74), + ], + textInputAction: TextInputAction.done, + maxLines: null, + autocorrect: false, + hintText: _showAddressHint ? l10n.addressHint : '', + prefixButton: TextFieldButton( + icon: AppIcons.scan, + onPressed: () async { + final scanResult = await UserDataUtil.scanQrCode(context); + final data = scanResult?.code; + if (data == null) { + UIUtil.showSnackbar(l10n.qrInvalidAddress, context); + } else { + final address = Address.tryParse( + data, + expectedPrefix: addressPrefix, + ); + if (mounted && address != null) { setState(() { + _addressController.text = address.toString(); + _addressValidationText = ""; _addressValid = true; - _showPasteButton = false; - _addressController.text = data; _addressValidAndUnfocused = true; }); _addressFocusNode.unfocus(); - } else { - setState(() { - _showPasteButton = true; - _addressValid = false; - }); } - }, - ), - fadeSuffixOnCondition: true, - suffixShowFirstCondition: _showPasteButton, - onChanged: (text) { - final address = Address.tryParse( - text, - expectedPrefix: addressPrefix, - ); - if (address != null) { + } + }, + ), + fadePrefixOnCondition: true, + prefixShowFirstCondition: _showPasteButton, + suffixButton: TextFieldButton( + icon: AppIcons.paste, + onPressed: () async { + if (!_showPasteButton) { + return; + } + String? data = + await UserDataUtil.getClipboardText(DataType.ADDRESS); + if (data != null) { setState(() { _addressValid = true; _showPasteButton = false; - _addressController.text = address.toString(); + _addressController.text = data; + _addressValidAndUnfocused = true; }); _addressFocusNode.unfocus(); } else { @@ -221,53 +197,73 @@ class _ContactAddSheetState extends ConsumerState { }); } }, - overrideTextFieldWidget: !_shouldShowTextField() - ? GestureDetector( - onTap: () { - if (widget.address != null) { - return; - } - setState(() { - _addressValidAndUnfocused = false; - }); - Future.delayed(Duration(milliseconds: 50), () { - FocusScope.of(context) - .requestFocus(_addressFocusNode); - }); - }, - child: AddressThreeLineText( - address: widget.address != null - ? widget.address! - : _addressController.text, - ), - ) - : null, ), - // Enter Address Error Container - Container( - margin: const EdgeInsets.only(top: 5, bottom: 5), - child: Text( - _addressValidationText, - style: styles.textStyleParagraphThinPrimary, - ), + fadeSuffixOnCondition: true, + suffixShowFirstCondition: _showPasteButton, + onChanged: (text) { + final address = Address.tryParse( + text, + expectedPrefix: addressPrefix, + ); + if (address != null) { + setState(() { + _addressValid = true; + _showPasteButton = false; + _addressController.text = address.toString(); + }); + _addressFocusNode.unfocus(); + } else { + setState(() { + _showPasteButton = true; + _addressValid = false; + }); + } + }, + overrideTextFieldWidget: !_shouldShowTextField() + ? GestureDetector( + onTap: () { + if (widget.address != null) { + return; + } + setState(() { + _addressValidAndUnfocused = false; + }); + Future.delayed(Duration(milliseconds: 50), () { + FocusScope.of(context).requestFocus(_addressFocusNode); + }); + }, + child: AddressThreeLineText( + address: widget.address != null + ? widget.address! + : _addressController.text, + ), + ) + : null, + ), + // Enter Address Error Container + Container( + margin: const EdgeInsets.only(top: 5, bottom: 5), + child: Text( + _addressValidationText, + style: styles.textStyleParagraphThinPrimary, ), - ], - ), - bottomWidget: Padding( - padding: const EdgeInsets.symmetric(horizontal: 28), - child: Column( - children: [ - PrimaryButton( - title: l10n.addContact, - onPressed: _addContact, - ), - const SizedBox(height: 16), - PrimaryOutlineButton( - title: l10n.close, - onPressed: () => appRouter.pop(context), - ), - ], ), + ], + ), + bottomWidget: Padding( + padding: const EdgeInsets.symmetric(horizontal: 28), + child: Column( + children: [ + PrimaryButton( + title: l10n.addContact, + onPressed: _addContact, + ), + const SizedBox(height: 16), + PrimaryOutlineButton( + title: l10n.close, + onPressed: () => appRouter.pop(context), + ), + ], ), ), ); diff --git a/lib/contacts/contact_details.dart b/lib/contacts/contact_details.dart index ae61fc9..3e93e98 100644 --- a/lib/contacts/contact_details.dart +++ b/lib/contacts/contact_details.dart @@ -68,13 +68,16 @@ class ContactDetails extends HookConsumerWidget { ); } - void showSendSheet() { + Future showSendSheet() async { appRouter.pop(context); - Sheets.showAppHeightNineSheet( - context: context, - theme: theme, - widget: SendSheet(contact: contact), - ); + final (:cont, :rbf) = await UIUtil.checkForPendingTx(context, ref: ref); + if (cont) { + Sheets.showAppHeightNineSheet( + context: context, + theme: theme, + widget: SendSheet(contact: contact, rbf: rbf), + ); + } } return SafeArea( diff --git a/lib/core/core_providers.dart b/lib/core/core_providers.dart index 354378c..106f33e 100644 --- a/lib/core/core_providers.dart +++ b/lib/core/core_providers.dart @@ -1,10 +1,12 @@ import 'dart:async'; import 'dart:math'; +import 'package:decimal/decimal.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:logger/logger.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import '../app_providers.dart'; import '../app_styles.dart'; import '../chain_state/chain_state.dart'; import '../database/database.dart'; @@ -12,14 +14,11 @@ import '../spectre/grpc/rpc.pb.dart'; import '../spectre/spectre.dart'; import '../main_card/main_card_notifier.dart'; import '../main_card/main_card_state.dart'; -import '../node_settings/node_providers.dart'; -import '../settings/settings_providers.dart'; import '../util/auth_util.dart'; import '../util/biometrics.dart'; import '../util/hapticutil.dart'; import '../util/sharedprefsutil.dart'; import '../util/vault.dart'; -import '../utxos/utxos_providers.dart'; final timeProvider = StreamProvider.autoDispose((ref) { return Stream.periodic( @@ -211,6 +210,92 @@ final appLinkProvider = StateProvider((ref) { final fiatModeProvider = StateProvider((ref) => false); +final pendingTxsProvider = FutureProvider.autoDispose((ref) async { + final client = ref.watch(spectreClientProvider); + final addresses = ref.watch(activeAddressesProvider); + // refresh when utxos change + ref.watch(utxosChangedProvider); + final pendingTxs = await client.getMempoolEntriesByAddresses( + addresses, + filterTransactionPool: false, + includeOrphanPool: false, + ); + return pendingTxs + .expand( + (entries) => entries.sending.map( + (e) => ApiTransaction.fromRpc(e.transaction), + ), + ) + .toSet() + .toList(); +}); +final rpcFeeEstimateProvider = FutureProvider.autoDispose((ref) async { + // refresh once every 10 seconds + ref.watch(timeProvider); + final client = ref.watch(spectreClientProvider); + try { + final feeEstimate = await client.getFeeEstimate(); + return feeEstimate; + } catch (e) { + return null; + } +}); + +extension BigIntExt on BigInt { + BigInt min(BigInt min) => this < min ? min : this; +} + +final feeEstimateProvider = Provider.family + .autoDispose, (BigInt, Amount)>((ref, massAndFee) { + final mass = massAndFee.$1; + final baseFee = massAndFee.$2; + final feeEstimate = ref.watch(rpcFeeEstimateProvider).valueOrNull; + if (feeEstimate == null) { + return [ + (Amount.value(Decimal.parse('0.001')), null), + (Amount.value(Decimal.parse('0.01')), null), + (Amount.value(Decimal.parse('0.1')), null), + ]; + } + Amount feeFor(double feeRate, BigInt mass, Amount baseFee) { + final estimate = feeRate * mass.toDouble(); + return Amount.raw((BigInt.from(estimate) - baseFee.raw).min(BigInt.zero)); + } + + final fees = [ + if (feeEstimate.lowBuckets.isNotEmpty) + ( + feeFor(feeEstimate.lowBuckets.first.feerate, mass, baseFee), + feeEstimate.lowBuckets.first.estimatedSeconds.toInt(), + ), + if (feeEstimate.normalBuckets.isNotEmpty) + ( + feeFor(feeEstimate.normalBuckets.first.feerate, mass, baseFee), + feeEstimate.normalBuckets.first.estimatedSeconds.toInt(), + ), + ( + feeFor(feeEstimate.priorityBucket.feerate, mass, baseFee), + feeEstimate.priorityBucket.estimatedSeconds.toInt(), + ), + ].where((fee) => fee.$1.raw > BigInt.zero).toList(); + return fees; +}); + +final sprSymbolProvider = Provider((ref) { + final network = ref.watch(networkProvider); + return switch (network) { + SpectreNetwork.mainnet => 'SPR', + _ => 'TSPR', + }; +}); +final symbolProvider = Provider.family((ref, amount) { + final sprSymbol = ref.watch(sprSymbolProvider); + if (amount.tokenInfo.tokenId != TokenInfo.spectre.tokenId) { + return amount.symbolLabel; + } + return sprSymbol; +}); + // provider for network statistics final networkStatsProvider = FutureProvider((ref) async { final api = ref.read(_spectreApiProvider) as SpectreApiMainnet; diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 65801a3..7550b0b 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -547,5 +547,36 @@ "bip39PassphraseEnter": "Enter Passphrase", "bip39PassphraseConfirm": "Confirm Passphrase", "bip39PassphraseMismatch": "Passphrases do not match!", - "bip39PassphraseNote": "Wallet with BIP39 passphrase." + "bip39PassphraseNote": "Wallet with BIP39 passphrase.", + "feePriorityHint": "Enter Priority Fee", + "feeTitle": "Manage Fee", + "feeBaseUppercase": "BASE FEE", + "feePriorityUppsercase": "PRIORITY FEE", + "feeUpdateAddressError": "Failed to recognise destination address", + "feeUpdateRebuildError": "Failed to rebuild transaction", + "feeUpdateRebuildError2": "Failed to rebuild transaction with new fee", + "feeUpdateError": "Failed to update fee", + "feeUpdate": "Update Fee", + "feeUpdateTitle": "Updating fee", + "feeSheetRecommendedPriority": "Recommended Priority Fees", + "feeSheetPriorityFeeWarning": "New priority fee must be at least {amount} {symbol}", + "@feeSheetPriorityFeeWarning": { + "placeholders": { + "amount": { + "type": "String" + }, + "symbol": { + "type": "String" + } + } + }, + "txPending": "PENDING", + "txPendingMessage": "This transaction is pending", + "txPendingTitle": "Pending Transaction", + "txPendingContent": "There is a pending transactions in the mempool.", + "txPendingActionUpdateFee": "Update Tx Fee", + "txPendingActionRbf": "Replace By Fee", + "txInMempool": "in mempool", + "utxoSelectionTitle": "Select UTXOs", + "utxoSelectionHint": "Please select more UTXOs" } \ No newline at end of file diff --git a/lib/send_sheet/balance_text_widget.dart b/lib/send_sheet/balance_text_widget.dart index 9e8282a..b953314 100644 --- a/lib/send_sheet/balance_text_widget.dart +++ b/lib/send_sheet/balance_text_widget.dart @@ -2,7 +2,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../app_providers.dart'; -import '../spectre/spectre.dart'; import '../wallet_address/wallet_address.dart'; final _balanceProvider = @@ -35,7 +34,7 @@ class BalanceRowWidget extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final styles = ref.watch(stylesProvider); - final tokenInfo = TokenInfo.spectre; + final sprSymbol = ref.watch(sprSymbolProvider); final balance = ref.watch(_balanceProvider(address)); final fiatValue = ref.watch(_fiatProvider(address)); @@ -54,7 +53,7 @@ class BalanceRowWidget extends ConsumerWidget { style: styles.textStyleBalanceAmountSmall, ), TextSpan( - text: ' ${tokenInfo.symbolLabel}', + text: ' $sprSymbol', style: styles.textStyleTransactionUnitSmall, ), ], @@ -94,7 +93,7 @@ class BalanceTextWidget extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final styles = ref.watch(stylesProvider); - final tokenInfo = TokenInfo.spectre; + final sprSymbol = ref.watch(sprSymbolProvider); final balance = ref.watch(_balanceProvider(address)); final fiatValue = ref.watch(_fiatProvider(address)); @@ -109,7 +108,7 @@ class BalanceTextWidget extends ConsumerWidget { style: styles.textStyleBalanceAmountMedium, ), TextSpan( - text: ' ${tokenInfo.symbolLabel}', + text: ' $sprSymbol', style: styles.textStyleTransactionUnitMedium, ), ], diff --git a/lib/send_sheet/fee_sheet.dart b/lib/send_sheet/fee_sheet.dart new file mode 100644 index 0000000..6c5dab2 --- /dev/null +++ b/lib/send_sheet/fee_sheet.dart @@ -0,0 +1,201 @@ +import 'dart:math'; + +import 'package:decimal/decimal.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import '../app_providers.dart'; +import '../app_router.dart'; +import '../spectre/spectre.dart'; +import '../l10n/l10n.dart'; +import '../util/numberutil.dart'; +import '../util/ui_util.dart'; +import '../widgets/amount_card.dart'; +import '../widgets/app_text_field.dart'; +import '../widgets/buttons.dart'; +import '../widgets/fiat_value_container.dart'; +import '../widgets/spr_icon_widget.dart'; +import '../widgets/sheet_widget.dart'; + +class FeeSheet extends HookConsumerWidget { + final Amount baseFee; + final Amount priorityFee; + final BigInt txMass; + final bool rbf; + + const FeeSheet({ + super.key, + required this.baseFee, + required this.priorityFee, + required this.txMass, + this.rbf = false, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = ref.watch(themeProvider); + final styles = ref.watch(stylesProvider); + final l10n = l10nOf(context); + + final spectreFormatter = ref.watch(spectreFormatterProvider); + //final symbol = ref.watch(sprSymbolProvider); + + final feeEstimate = ref.watch(feeEstimateProvider((txMass, baseFee))); + + final amount = useState(priorityFee); + + final controller = useTextEditingController( + text: priorityFee == Amount.zero + ? null + : NumberUtil.textFieldFormatedAmount(priorityFee), + ); + final focusNode = useFocusNode(); + + final hint = useState(null); + + useEffect(() { + final listener = () { + hint.value = focusNode.hasFocus ? '' : null; + }; + focusNode.addListener(listener); + return () => focusNode.removeListener(listener); + }, [focusNode]); + + void onValueChanged(String text) { + final value = spectreFormatter.tryParse(text); + + if (value == null) { + amount.value = null; + return; + } + amount.value = Amount.value(value); + } + + void clearAmount() { + amount.value = null; + controller.clear(); + } + + void confirmFee() { + if (rbf && (amount.value ?? Amount.zero).raw < priorityFee.raw) { + final symbol = ref.watch(sprSymbolProvider); + final amountStr = NumberUtil.formatedAmount(priorityFee); + UIUtil.showSnackbar( + l10n.feeSheetPriorityFeeWarning(amountStr, symbol), + context, + ); + return; + } + appRouter.pop(context, withResult: amount.value ?? Amount.zero); + } + + return SheetWidget( + title: l10n.feeTitle, + mainWidget: Column( + children: [ + const SizedBox(height: 20), + Text( + l10n.feeBaseUppercase, + style: styles.textStyleSubHeader, + ), + const SizedBox(height: 15), + AmountCard(amount: baseFee), + const SizedBox(height: 40), + Text( + l10n.feePriorityUppsercase, + style: styles.textStyleSubHeader, + ), + FiatValueContainer( + amount: amount.value ?? Amount.zero, + hint: l10n.optionalLabel, + child: AppTextField( + focusNode: focusNode, + controller: controller, + topMargin: 15, + cursorColor: theme.primary, + style: styles.textStyleParagraphPrimary, + inputFormatters: [spectreFormatter], + onChanged: onValueChanged, + textInputAction: TextInputAction.done, + maxLines: null, + autocorrect: false, + hintText: hint.value ?? l10n.feePriorityHint, + prefixButton: TextFieldButton(widget: SprIconWidget()), + suffixButton: TextFieldButton( + icon: Icons.clear, + onPressed: clearAmount, + ), + fadeSuffixOnCondition: true, + suffixShowFirstCondition: + (amount.value?.value ?? Decimal.zero) > Decimal.zero, + keyboardType: + const TextInputType.numberWithOptions(decimal: true), + textAlign: TextAlign.center, + ), + ), + if (feeEstimate.isNotEmpty) ...[ + const SizedBox(height: 20), + Text( + l10n.feeSheetRecommendedPriority, + style: styles.textStyleTokenSymbolSuccess, + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 10), + child: FittedBox( + fit: BoxFit.scaleDown, + child: Row( + children: [ + for (final fee in feeEstimate) + Column( + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: ActionChip( + label: Text( + '${fee.$1}', + ), + labelStyle: + styles.textStyleTransactionAmountSmall, + padding: const EdgeInsets.symmetric( + horizontal: 8, vertical: 12), + onPressed: () { + final text = fee.$1.toString(); + controller.text = text; + onValueChanged(text); + }, + ), + ), + if (fee.$2 != null) + Text( + '< ${max(fee.$2!, 1)} s', + style: styles.textStyleParagraphThinSuccess, + ), + ], + ), + ], + ), + ), + ), + ] + ], + ), + bottomWidget: Padding( + padding: const EdgeInsets.symmetric(horizontal: 28), + child: Column( + children: [ + PrimaryButton( + title: l10n.confirm, + onPressed: confirmFee, + ), + const SizedBox(height: 16), + PrimaryOutlineButton( + title: l10n.cancel, + onPressed: () => appRouter.pop(context), + ), + ], + ), + ), + ); + } +} diff --git a/lib/send_sheet/fee_widget.dart b/lib/send_sheet/fee_widget.dart index f2f2456..0b970a0 100644 --- a/lib/send_sheet/fee_widget.dart +++ b/lib/send_sheet/fee_widget.dart @@ -18,6 +18,8 @@ class FeeWidget extends ConsumerWidget { final styles = ref.watch(stylesProvider); final l10n = l10nOf(context); + final symbol = ref.watch(symbolProvider(amount)); + return Container( width: double.infinity, margin: EdgeInsets.only( @@ -40,7 +42,7 @@ class FeeWidget extends ConsumerWidget { style: styles.textStyleDataTypeHeaderHighlight, ), Text( - '${amount.value} ${amount.symbolLabel}', + '${amount.value} $symbol', textAlign: TextAlign.center, style: styles.textStyleAddressText90, ), diff --git a/lib/send_sheet/send_complete_sheet.dart b/lib/send_sheet/send_complete_sheet.dart index be2bb14..6ce321a 100644 --- a/lib/send_sheet/send_complete_sheet.dart +++ b/lib/send_sheet/send_complete_sheet.dart @@ -20,6 +20,7 @@ class SendCompleteSheet extends HookConsumerWidget { final Address toAddress; final String txId; final String? note; + final bool rbf; const SendCompleteSheet({ Key? key, @@ -27,6 +28,7 @@ class SendCompleteSheet extends HookConsumerWidget { required this.toAddress, required this.txId, this.note, + this.rbf = false, }) : super(key: key); @override diff --git a/lib/send_sheet/send_confirm_sheet.dart b/lib/send_sheet/send_confirm_sheet.dart index 41aa560..f67e1ff 100644 --- a/lib/send_sheet/send_confirm_sheet.dart +++ b/lib/send_sheet/send_confirm_sheet.dart @@ -1,6 +1,8 @@ import 'dart:async'; +import 'package:fast_immutable_collections/fast_immutable_collections.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../app_providers.dart'; @@ -9,23 +11,28 @@ import '../spectre/spectre.dart'; import '../l10n/l10n.dart'; import '../util/numberutil.dart'; import '../util/ui_util.dart'; +import '../utxos/utxos_selection_page.dart'; import '../widgets/address_card.dart'; import '../widgets/amount_card.dart'; +import '../widgets/app_text_field.dart'; import '../widgets/buttons.dart'; import '../widgets/dialog.dart'; import '../widgets/gradient_widgets.dart'; import '../widgets/sheet_util.dart'; import '../widgets/sheet_widget.dart'; +import 'fee_sheet.dart'; import 'send_complete_sheet.dart'; import 'send_note_widget.dart'; class SendConfirmSheet extends HookConsumerWidget { - final SendTx tx; + final SendTx sendTx; + final bool rbf; SendConfirmSheet({ - Key? key, - required this.tx, - }) : super(key: key); + super.key, + required this.sendTx, + this.rbf = false, + }); @override Widget build(BuildContext context, WidgetRef ref) { @@ -33,15 +40,14 @@ class SendConfirmSheet extends HookConsumerWidget { final styles = ref.watch(stylesProvider); final l10n = l10nOf(context); + final sendTxState = useState(sendTx); + final tx = sendTxState.value; + final toAddress = tx.toAddress; final amount = tx.amount; - final fee = tx.fee; final note = tx.note; - final title = l10n.sendConfirm; - final toTitle = l10n.sendToAddressTitle; - Future sendTransaction() async { final walletService = ref.read(walletServiceProvider); @@ -52,12 +58,8 @@ class SendConfirmSheet extends HookConsumerWidget { l10n.sendTxProgressDescription, ); - final txId = await walletService.sendTransaction(tx); - - if (tx.changeAddress case final changeAddress?) { - final addressNotifier = ref.read(addressNotifierProvider); - await addressNotifier.markUsed([changeAddress.encoded]); - } + final txId = await walletService.sendTransaction(tx.tx, rbf: rbf); + ref.invalidate(pendingTxsProvider); if (tx.note case final txNote?) { final notes = ref.read(txNotesProvider); @@ -67,10 +69,11 @@ class SendConfirmSheet extends HookConsumerWidget { appRouter.pop(context); final sheet = SendCompleteSheet( - amount: tx.amount, - toAddress: tx.toAddress, + amount: amount, + toAddress: toAddress, txId: txId, note: tx.note, + rbf: rbf, ); Sheets.showAppHeightNineSheet( @@ -90,31 +93,125 @@ class SendConfirmSheet extends HookConsumerWidget { } String authMessage() { + final symbol = ref.read(symbolProvider(amount)); final formatedAmount = NumberUtil.formatedAmount(amount); - return '${l10n.sendConfirm} ${formatedAmount} ${amount.symbolLabel}'; + return '${l10n.sendConfirm} $formatedAmount $symbol'; + } + + bool checkInsufficientBalance() { + final balance = ref.read(totalBalanceProvider); + + return balance.raw < amount.raw + fee.raw; } - String? checkMissingBalance() { - final balanceRaw = ref.read(totalBalanceProvider).raw; + Future updateTx({ + List? selectedUtxos, + required Amount priorityFee, + }) async { + final addressNotifier = ref.read(addressNotifierProvider); + final changeAddress = await addressNotifier.nextChangeAddress; + final spendableUtxos = ref.read(spendableUtxosProvider); + final txBuilder = TransactionBuilder( + utxos: spendableUtxos, + feePerInput: kFeePerInput, + priorityFee: priorityFee, + ); + + final newTx = txBuilder.createUnsignedTransaction( + toAddress: toAddress, + amountRaw: amount.raw, + changeAddress: changeAddress.address, + preselectedUtxos: selectedUtxos, + ); + + sendTxState.value = tx.copyWith( + tx: newTx, + utxos: txBuilder.selectedUtxos, + userSelected: selectedUtxos != null, + change: txBuilder.change, + changeAddress: txBuilder.changeAddress, + baseFee: txBuilder.baseFee, + priorityFee: txBuilder.priorityFee, + ); + } - if (balanceRaw < amount.raw + fee) { - return amount.symbolLabel; + Future selectUtxos({required Amount priorityFee}) async { + final notifier = ref.read(selectedUtxosProvider.notifier); + notifier.state = ISet(tx.userSelectedUtxos); + + final selectedUtxos = await Sheets.showAppHeightNineSheet>( + context: context, + theme: theme, + widget: UtxosSelectionPage(tx: tx.copyWith(priorityFee: priorityFee)), + ); + + if (selectedUtxos != null) { + await updateTx( + selectedUtxos: selectedUtxos, + priorityFee: priorityFee, + ); + } + } + + Future adjustFee({Amount? requiredPriorityFee}) async { + Amount priorityFee = tx.priorityFee; + if (requiredPriorityFee != null && + requiredPriorityFee.raw > priorityFee.raw) { + priorityFee = requiredPriorityFee; } - return null; + final newPriorityFee = await Sheets.showAppHeightNineSheet( + context: context, + widget: FeeSheet( + baseFee: tx.baseFee, + priorityFee: priorityFee, + txMass: tx.mass, + rbf: rbf, + ), + theme: theme, + ); + + if (newPriorityFee != null) { + try { + await updateTx( + selectedUtxos: tx.userSelectedUtxos, + priorityFee: newPriorityFee, + ); + } catch (e) { + // if (tx.userSelected) { + // selectUtxos(priorityFee: newPriorityFee); + // } else { + UIUtil.showSnackbar(l10n.insufficientBalance, context); + appRouter.pop(context); + //} + } + } } Future onConfirm() async { - final symbolLabel = checkMissingBalance(); - if (symbolLabel != null) { + final symbol = ref.read(sprSymbolProvider); + final insufficientBalance = checkInsufficientBalance(); + if (insufficientBalance) { AppDialogs.showInfoDialog( context, l10n.insufficientBalance, - l10n.insufficientBalanceDetails, + l10n.insufficientBalanceDetails.replaceAll('SPR', symbol), ); return; } + // handle RBF + if (rbf) { + final pendingTx = ref.read(txNotifierProvider).pendingTxs.first; + final fees = pendingTx.fees; + if (tx.fee.raw <= fees.baseFee.raw + fees.priorityFee.raw) { + final requiredPriorityFee = + Amount.raw(fees.priorityFee.raw + BigInt.one); + adjustFee(requiredPriorityFee: requiredPriorityFee); + return; + } + } + // Authenticate final walletAuth = ref.read(walletAuthProvider.notifier); final authUtil = ref.read(authUtilProvider); @@ -134,7 +231,7 @@ class SendConfirmSheet extends HookConsumerWidget { } return SheetWidget( - title: title, + title: l10n.sendConfirm, mainWidget: Stack( children: [ SingleChildScrollView( @@ -142,12 +239,18 @@ class SendConfirmSheet extends HookConsumerWidget { child: Column( mainAxisSize: MainAxisSize.min, children: [ - AmountCard(amount: amount), + AmountCard( + amount: amount, + // rightButton: TextFieldButton( + // icon: Icons.sort, + // onPressed: () => selectUtxos(priorityFee: tx.priorityFee), + // ), + ), // "TO" text Container( margin: const EdgeInsets.only(top: 30, bottom: 10), child: Text( - toTitle.toUpperCase(), + l10n.sendToAddressTitle.toUpperCase(), style: styles.textStyleSubHeader, ), ), @@ -159,7 +262,13 @@ class SendConfirmSheet extends HookConsumerWidget { style: styles.textStyleSubHeader, ), ), - AmountCard(amount: Amount.raw(fee)), + AmountCard( + amount: fee, + rightButton: TextFieldButton( + icon: Icons.add, + onPressed: adjustFee, + ), + ), if (note != null) Padding( padding: const EdgeInsets.only( diff --git a/lib/send_sheet/send_sheet.dart b/lib/send_sheet/send_sheet.dart index 55f1241..68b70ce 100644 --- a/lib/send_sheet/send_sheet.dart +++ b/lib/send_sheet/send_sheet.dart @@ -36,14 +36,16 @@ class SendSheet extends ConsumerStatefulWidget { final Contact? contact; final SpectreUri? uri; final BigInt? feeRaw; + final bool rbf; const SendSheet({ - Key? key, + super.key, this.title, this.contact, this.uri, this.feeRaw, - }) : super(key: key); + this.rbf = false, + }); _SendSheetState createState() => _SendSheetState(); } @@ -84,7 +86,7 @@ class _SendSheetState extends ConsumerState { late BigInt? amountRaw = widget.uri?.amount?.raw; late BigInt? feeRaw = widget.feeRaw; - late String? _note = widget.uri?.message; + String? get _note => widget.uri?.message; bool get hasNote => _note != null; bool get hasUri => widget.uri != null; @@ -237,7 +239,6 @@ class _SendSheetState extends ConsumerState { final note = uri?.message; if (note != null) { _noteController.text = note; - _note = note; } // See if this address belongs to a contact @@ -304,10 +305,8 @@ class _SendSheetState extends ConsumerState { return; } - final note = _noteController.text; - if (_note == null && note.isNotEmpty) { - _note = note; - } + final text = _noteController.text; + final note = text.isNotEmpty ? text : _note; final uri = SpectreUri( address: toAddress, @@ -315,7 +314,7 @@ class _SendSheetState extends ConsumerState { message: note, ); - UIUtil.showSendFlow(context, ref: ref, uri: uri); + UIUtil.showSendFlow(context, ref: ref, uri: uri, useRbf: widget.rbf); } return SafeArea( @@ -613,7 +612,7 @@ class _SendSheetState extends ConsumerState { if (amountRaw! > maxSend.raw) { showAppDialog( context: context, - builder: (_) => const CompoundUtxosDialog(lightMode: true), + builder: (_) => CompoundUtxosDialog(lightMode: true, rbf: widget.rbf), ); return false; } @@ -967,6 +966,25 @@ class _SendSheetState extends ConsumerState { textInputAction: TextInputAction.done, maxLines: null, autocorrect: false, + hintText: _noteHint ?? l10n.enterNote, + // prefixButton: TextFieldButton( + // icon: AppIcons.scan, + // onPressed: () async { + // FocusManager.instance.primaryFocus?.unfocus(); + + // final qr = await UserDataUtil.scanQrCode(context); + // final data = qr?.code; + // if (data == null) { + // return; + // } + + // _noteController.text = data; + // _notePasteButtonVisible = false; + // _noteQrButtonVisible = false; + + // setState(() => _noteValidAndUnfocused = true); + // }, + // ), fadePrefixOnCondition: true, prefixShowFirstCondition: _noteQrButtonVisible, suffixButton: TextFieldButton( @@ -978,14 +996,13 @@ class _SendSheetState extends ConsumerState { Clipboard.getData("text/plain").then((ClipboardData? data) { final text = data?.text; - if (text == null) { + if (text == null || text.isEmpty) { return; } FocusManager.instance.primaryFocus?.unfocus(); _noteController.text = text; _notePasteButtonVisible = false; _noteQrButtonVisible = false; - _note = text; setState(() => _noteValidAndUnfocused = true); }); diff --git a/lib/settings_advanced/advanced_menu.dart b/lib/settings_advanced/advanced_menu.dart index 57cc69d..edb4f2e 100644 --- a/lib/settings_advanced/advanced_menu.dart +++ b/lib/settings_advanced/advanced_menu.dart @@ -7,6 +7,7 @@ import '../l10n/l10n.dart'; import '../settings_drawer/double_line_item_two.dart'; import '../transactions/tx_filter_settings.dart'; import '../tx_report/tx_report_sheet.dart'; +import '../util/ui_util.dart'; import '../widgets/app_icon_button.dart'; import '../widgets/app_simpledialog.dart'; import '../widgets/gradient_widgets.dart'; @@ -32,10 +33,13 @@ class AdvancedMenu extends ConsumerWidget { final wallet = ref.watch(walletProvider); Future compoundUtxos() async { - await showAppDialog( - context: context, - builder: (_) => const CompoundUtxosDialog(), - ); + final (:cont, :rbf) = await UIUtil.checkForPendingTx(context, ref: ref); + if (cont) { + showAppDialog( + context: context, + builder: (_) => CompoundUtxosDialog(rbf: rbf), + ); + } } Future scanMoreAddresses() async { diff --git a/lib/settings_advanced/compound_utxos_dialog.dart b/lib/settings_advanced/compound_utxos_dialog.dart index 6c6f1a6..752d189 100644 --- a/lib/settings_advanced/compound_utxos_dialog.dart +++ b/lib/settings_advanced/compound_utxos_dialog.dart @@ -12,10 +12,12 @@ import '../widgets/dialog.dart'; class CompoundUtxosDialog extends ConsumerWidget { final bool lightMode; + final bool rbf; const CompoundUtxosDialog({ super.key, this.lightMode = false, + required this.rbf, }); @override @@ -26,6 +28,7 @@ class CompoundUtxosDialog extends ConsumerWidget { final utxos = ref.watch(utxoListProvider); final balance = ref.watch(formatedTotalBalanceProvider); final maxSend = NumberUtil.formatedAmount(ref.watch(maxSendProvider)); + final sprSymbol = ref.watch(sprSymbolProvider); Future sendCompoundTx() async { try { @@ -45,13 +48,21 @@ class CompoundUtxosDialog extends ConsumerWidget { appRouter.pop(context); return; } + + Amount? priorityFee; + if (rbf) { + final pendingTx = ref.read(txNotifierProvider).pendingTxs.first; + priorityFee = Amount.raw(pendingTx.fees.priorityFee.raw + BigInt.one); + } + final compoundTx = walletService.createCompoundTx( compoundAddress: changeAddress.address, spendableUtxos: spendableUtxos, feePerInput: kFeePerInput, + priorityFee: priorityFee, ); - await walletService.sendTransaction(compoundTx); - await addressNotifier.addAddress(changeAddress.copyWith(used: true)); + await walletService.sendTransaction(compoundTx.tx, rbf: rbf); + ref.invalidate(pendingTxsProvider); if (lightMode) { // give some time for compound tx to broadcast and get accepted @@ -62,7 +73,7 @@ class CompoundUtxosDialog extends ConsumerWidget { UIUtil.showSnackbar(l10n.compoundSuccess, context); } catch (e) { - UIUtil.showSnackbar('${l10n.compoundFailure}: $e', context); + UIUtil.showSnackbar(l10n.compoundFailure, context); } finally { appRouter.pop(context); } @@ -127,13 +138,13 @@ class CompoundUtxosDialog extends ConsumerWidget { Container( padding: EdgeInsets.symmetric(vertical: 8), child: Text( - '$balance SPR', + '$balance $sprSymbol', style: styles.textStyleSettingItemHeader, ), ), Container( child: Text( - '${maxSend} SPR', + '$maxSend $sprSymbol', style: styles.textStyleSettingItemHeader, ), ), diff --git a/lib/settings_drawer/disable_password_sheet.dart b/lib/settings_drawer/disable_password_sheet.dart index d04c197..fe65154 100644 --- a/lib/settings_drawer/disable_password_sheet.dart +++ b/lib/settings_drawer/disable_password_sheet.dart @@ -10,7 +10,6 @@ import '../util/ui_util.dart'; import '../widgets/app_text_field.dart'; import '../widgets/buttons.dart'; import '../widgets/sheet_widget.dart'; -import '../widgets/tap_outside_unfocus.dart'; class DisablePasswordSheet extends HookConsumerWidget { const DisablePasswordSheet({Key? key}) : super(key: key); @@ -41,60 +40,58 @@ class DisablePasswordSheet extends HookConsumerWidget { } } - return TapOutsideUnfocus( - child: SheetWidget( - title: l10n.disablePasswordSheetHeader, - mainWidget: Column(children: [ + return SheetWidget( + title: l10n.disablePasswordSheetHeader, + mainWidget: Column(children: [ + Container( + margin: EdgeInsetsDirectional.only(start: 40, end: 40, top: 16), + child: AutoSizeText( + l10n.passwordNoLongerRequiredToOpenParagraph, + style: styles.textStyleParagraph, + maxLines: 5, + stepGranularity: 0.5, + ), + ), + Column(children: [ + AppTextField( + topMargin: 30, + padding: EdgeInsetsDirectional.only(start: 16, end: 16), + focusNode: passwordFocusNode, + controller: passwordController, + textInputAction: TextInputAction.done, + maxLines: 1, + autocorrect: false, + onChanged: (String newText) { + passwordError.value = ''; + }, + hintText: l10n.enterPasswordHint, + keyboardType: TextInputType.text, + obscureText: true, + style: styles.textStyleParagraphText, + ), Container( - margin: EdgeInsetsDirectional.only(start: 40, end: 40, top: 16), - child: AutoSizeText( - l10n.passwordNoLongerRequiredToOpenParagraph, - style: styles.textStyleParagraph, - maxLines: 5, - stepGranularity: 0.5, + alignment: AlignmentDirectional(0, 0), + margin: EdgeInsets.only(top: 3), + child: Text( + passwordError.value, + style: styles.textStyleParagraphThinPrimary, ), ), - Column(children: [ - AppTextField( - topMargin: 30, - padding: EdgeInsetsDirectional.only(start: 16, end: 16), - focusNode: passwordFocusNode, - controller: passwordController, - textInputAction: TextInputAction.done, - maxLines: 1, - autocorrect: false, - onChanged: (String newText) { - passwordError.value = ''; - }, - hintText: l10n.enterPasswordHint, - keyboardType: TextInputType.text, - obscureText: true, - style: styles.textStyleParagraphText, - ), - Container( - alignment: AlignmentDirectional(0, 0), - margin: EdgeInsets.only(top: 3), - child: Text( - passwordError.value, - style: styles.textStyleParagraphThinPrimary, - ), - ), - ]), ]), - bottomWidget: Padding( - padding: const EdgeInsets.symmetric(horizontal: 28), - child: Column(children: [ - PrimaryButton( - title: l10n.disablePasswordSheetHeader, - onPressed: submitAndDecrypt, - ), - const SizedBox(height: 16), - PrimaryOutlineButton( - title: l10n.close, - onPressed: () => appRouter.pop(context), - ), - ]), - ), + ]), + bottomWidget: Padding( + padding: const EdgeInsets.symmetric(horizontal: 28), + child: Column(children: [ + PrimaryButton( + title: l10n.disablePasswordSheetHeader, + onPressed: submitAndDecrypt, + ), + const SizedBox(height: 16), + PrimaryOutlineButton( + title: l10n.close, + onPressed: () => appRouter.pop(context), + ), + ]), ), ); } diff --git a/lib/settings_drawer/set_password_sheet.dart b/lib/settings_drawer/set_password_sheet.dart index c2b407f..2d57ed0 100644 --- a/lib/settings_drawer/set_password_sheet.dart +++ b/lib/settings_drawer/set_password_sheet.dart @@ -10,7 +10,6 @@ import '../util/ui_util.dart'; import '../widgets/app_text_field.dart'; import '../widgets/buttons.dart'; import '../widgets/sheet_widget.dart'; -import '../widgets/tap_outside_unfocus.dart'; class SetPasswordSheet extends HookConsumerWidget { const SetPasswordSheet({Key? key}) : super(key: key); @@ -66,84 +65,82 @@ class SetPasswordSheet extends HookConsumerWidget { } } - return TapOutsideUnfocus( - child: SheetWidget( - title: l10n.createPasswordSheetHeader, - mainWidget: Column( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Container( - margin: EdgeInsetsDirectional.only(start: 40, end: 40, top: 16), - child: AutoSizeText( - l10n.passwordWillBeRequiredToOpenParagraph, - style: styles.textStyleParagraph, - maxLines: 5, - stepGranularity: 0.5, - ), + return SheetWidget( + title: l10n.createPasswordSheetHeader, + mainWidget: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Container( + margin: EdgeInsetsDirectional.only(start: 40, end: 40, top: 16), + child: AutoSizeText( + l10n.passwordWillBeRequiredToOpenParagraph, + style: styles.textStyleParagraph, + maxLines: 5, + stepGranularity: 0.5, ), - // Create a Password Text Field - AppTextField( - topMargin: 30, - padding: EdgeInsetsDirectional.only(start: 16, end: 16), - focusNode: createFocusNode, - controller: createController, - textInputAction: TextInputAction.next, - maxLines: 1, - autocorrect: false, - onChanged: inputChanged, - hintText: l10n.createPasswordHint, - keyboardType: TextInputType.text, - obscureText: true, - textAlign: TextAlign.center, - style: textStyle, - onSubmitted: (text) { - confirmFocusNode.requestFocus(); - }, + ), + // Create a Password Text Field + AppTextField( + topMargin: 30, + padding: EdgeInsetsDirectional.only(start: 16, end: 16), + focusNode: createFocusNode, + controller: createController, + textInputAction: TextInputAction.next, + maxLines: 1, + autocorrect: false, + onChanged: inputChanged, + hintText: l10n.createPasswordHint, + keyboardType: TextInputType.text, + obscureText: true, + textAlign: TextAlign.center, + style: textStyle, + onSubmitted: (text) { + confirmFocusNode.requestFocus(); + }, + ), + // Confirm Password Text Field + AppTextField( + topMargin: 20, + padding: const EdgeInsetsDirectional.only( + start: 16, + end: 16, ), - // Confirm Password Text Field - AppTextField( - topMargin: 20, - padding: const EdgeInsetsDirectional.only( - start: 16, - end: 16, - ), - focusNode: confirmFocusNode, - controller: confirmController, - textInputAction: TextInputAction.done, - maxLines: 1, - autocorrect: false, - onChanged: inputChanged, - hintText: l10n.confirmPasswordHint, - keyboardType: TextInputType.text, - obscureText: true, - textAlign: TextAlign.center, - style: textStyle, + focusNode: confirmFocusNode, + controller: confirmController, + textInputAction: TextInputAction.done, + maxLines: 1, + autocorrect: false, + onChanged: inputChanged, + hintText: l10n.confirmPasswordHint, + keyboardType: TextInputType.text, + obscureText: true, + textAlign: TextAlign.center, + style: textStyle, + ), + // Error Text + Container( + alignment: AlignmentDirectional(0, 0), + margin: EdgeInsets.only(top: 3), + child: Text( + passwordError.value, + style: styles.textStyleParagraphThinPrimary, ), - // Error Text - Container( - alignment: AlignmentDirectional(0, 0), - margin: EdgeInsets.only(top: 3), - child: Text( - passwordError.value, - style: styles.textStyleParagraphThinPrimary, - ), - ), - ], - ), - bottomWidget: Padding( - padding: const EdgeInsets.symmetric(horizontal: 28), - child: Column(children: [ - PrimaryButton( - title: l10n.setPassword, - onPressed: submitAndEncrypt, - ), - const SizedBox(height: 16), - PrimaryOutlineButton( - title: l10n.close, - onPressed: () => appRouter.pop(context), - ), - ]), - ), + ), + ], + ), + bottomWidget: Padding( + padding: const EdgeInsets.symmetric(horizontal: 28), + child: Column(children: [ + PrimaryButton( + title: l10n.setPassword, + onPressed: submitAndEncrypt, + ), + const SizedBox(height: 16), + PrimaryOutlineButton( + title: l10n.close, + onPressed: () => appRouter.pop(context), + ), + ]), ), ); } diff --git a/lib/spectre/client/spectre_client.dart b/lib/spectre/client/spectre_client.dart index 3740ea9..5c0f9c3 100644 --- a/lib/spectre/client/spectre_client.dart +++ b/lib/spectre/client/spectre_client.dart @@ -210,6 +210,39 @@ class SpectreClient { return result.submitTransactionResponse.transactionId; } + Future<({String transactionId, RpcTransaction replacedTransaction})> + submitTransactionReplacement(RpcTransaction transaction) async { + final message = SpectredRequest( + submitTransactionReplacementRequest: + SubmitTransactionReplacementRequestMessage( + transaction: transaction, + ), + ); + final result = await _singleRequest(message); + final response = result.submitTransactionReplacementResponse; + final error = response.error; + if (error.message.isNotEmpty) { + throw RpcException(error); + } + return ( + transactionId: response.transactionId, + replacedTransaction: response.replacedTransaction, + ); + } + + // Fee Estimate + Future getFeeEstimate() async { + final message = SpectredRequest( + getFeeEstimateRequest: GetFeeEstimateRequestMessage(), + ); + final result = await _singleRequest(message); + final error = result.getFeeEstimateResponse.error; + if (error.message.isNotEmpty) { + throw RpcException(error); + } + return result.getFeeEstimateResponse.estimate; + } + // Mempool Future getMempoolEntry({ diff --git a/lib/spectre/transaction/transaction_builder.dart b/lib/spectre/transaction/transaction_builder.dart index 9f1c1f2..13bb910 100644 --- a/lib/spectre/transaction/transaction_builder.dart +++ b/lib/spectre/transaction/transaction_builder.dart @@ -1,55 +1,125 @@ +import 'dart:collection'; import 'dart:typed_data'; import 'package:fixnum/fixnum.dart'; -import '../types.dart'; -import '../utils.dart'; +import '../spectre.dart'; import 'txscript.dart'; -import 'types.dart'; class TransactionBuilder { final List utxos; - final BigInt feePerInput; + final BigInt feePerInputRaw; + final Amount priorityFee; + + BigInt _change = BigInt.zero; + Amount get change => Amount.raw(_change); Address? _changeAddress; Address? get changeAddress => _changeAddress; - BigInt _fee = BigInt.zero; - BigInt get fee => _fee; + + BigInt _baseFee = BigInt.zero; + Amount get baseFee => Amount.raw(_baseFee); + + List _selectedUtxos = []; + List get selectedUtxos => UnmodifiableListView(_selectedUtxos); TransactionBuilder({ required this.utxos, BigInt? feePerInput, - }) : feePerInput = feePerInput ?? kFeePerInput; + Amount? priorityFee, + }) : feePerInputRaw = feePerInput ?? kFeePerInput, + priorityFee = priorityFee ?? Amount.zero; + + Transaction? rebuildTransaction( + ApiTransaction tx, { + required Address toAddress, + required Address changeAddress, + }) { + final amountRaw = BigInt.from(tx.outputs.first.amount); + + final utxoMap = tx.inputs + .map( + (input) => ( + input.previousOutpointHash, + input.previousOutpointIndex.toInt(), + ), + ) + .toSet(); + final txUtxos = []; + for (final utxo in utxos) { + final outpoint = (utxo.outpoint.transactionId, utxo.outpoint.index); + if (utxoMap.contains(outpoint)) { + txUtxos.add(utxo); + utxoMap.remove(outpoint); + if (utxoMap.isEmpty) { + break; + } + } + } + + int index = 0; + while (index++ < kMaxInputsPerTransaction) { + try { + final tx = createUnsignedTransaction( + toAddress: toAddress, + amountRaw: amountRaw, + changeAddress: changeAddress, + preselectedUtxos: txUtxos, + ); + return tx; + } catch (e) { + if (utxos.length == txUtxos.length) { + // Not enough funds + rethrow; + } + final newUtxo = utxos.firstWhere( + (utxo) => !selectedUtxos.contains(utxo), + ); + txUtxos.add(newUtxo); + } + } + return null; + } Transaction createUnsignedTransaction({ required Address toAddress, - required Amount amount, + required BigInt amountRaw, required Address changeAddress, + List? preselectedUtxos, }) { - final selectedUtxos = _selectUtxos(spendAmount: amount.raw); + _selectedUtxos = preselectedUtxos != null + ? _userSelectedUtxos( + userSelectedUtxos: preselectedUtxos, + spendAmountRaw: amountRaw, + ) + : _selectUtxos(spendAmount: amountRaw); final changeAmount = _getChangeAmountRaw( - selectedUtxos: selectedUtxos, - spendAmount: amount.raw, + selectedUtxos: _selectedUtxos, + spendAmount: amountRaw, ); - final hasChange = changeAmount >= kMinChangeTarget; + final hasChange = changeAmount >= kMinChangeTarget || + changeAmount >= amountRaw ~/ BigInt.two; final payments = { - toAddress: amount.raw.toInt64(), + toAddress: amountRaw.toInt64(), if (hasChange) changeAddress: changeAmount.toInt64(), }; if (hasChange) { + _change = changeAmount; _changeAddress = changeAddress; - _fee = feePerInput * BigInt.from(selectedUtxos.length); + _baseFee = feePerInputRaw * BigInt.from(_selectedUtxos.length); } else { + _change = BigInt.zero; _changeAddress = null; - _fee = feePerInput * BigInt.from(selectedUtxos.length) + changeAmount; + _baseFee = + feePerInputRaw * BigInt.from(_selectedUtxos.length) + changeAmount; } final unsignedTransaction = _createUnsignedTransaction( - utxos: selectedUtxos, + utxos: _selectedUtxos, payments: payments, ); @@ -94,17 +164,38 @@ class TransactionBuilder { return tx; } + List _userSelectedUtxos({ + required List userSelectedUtxos, + required BigInt spendAmountRaw, + }) { + final selectedUtxos = List.of(userSelectedUtxos); + final totalValue = selectedUtxos.fold( + BigInt.zero, + (total, utxo) => total + utxo.utxoEntry.amount, + ); + + final baseFeeRaw = feePerInputRaw * BigInt.from(selectedUtxos.length); + final totalSpendRaw = spendAmountRaw + baseFeeRaw + priorityFee.raw; + + if (totalValue < totalSpendRaw) { + throw Exception('Not enough funds'); + } + + return selectedUtxos; + } + List _selectUtxos({ required BigInt spendAmount, }) { final selectedUtxos = []; var totalValue = BigInt.zero; + for (final utxo in utxos) { selectedUtxos.add(utxo); totalValue += utxo.utxoEntry.amount; - final fee = feePerInput * BigInt.from(selectedUtxos.length); - final totalSpend = spendAmount + fee; + final baseFeeRaw = feePerInputRaw * BigInt.from(selectedUtxos.length); + final totalSpend = spendAmount + baseFeeRaw + priorityFee.raw; if (totalValue == totalSpend || (totalValue >= totalSpend + kMinChangeTarget && @@ -113,8 +204,8 @@ class TransactionBuilder { } } - final fee = feePerInput * BigInt.from(selectedUtxos.length); - final totalSpend = spendAmount + fee; + final baseFeeRaw = feePerInputRaw * BigInt.from(selectedUtxos.length); + final totalSpend = spendAmount + baseFeeRaw + priorityFee.raw; if (totalValue < totalSpend) { throw Exception('Not enough funds'); @@ -133,7 +224,8 @@ class TransactionBuilder { totalValue += utxo.utxoEntry.amount; } - final fee = feePerInput * BigInt.from(selectedUtxos.length); + final baseFeeRaw = feePerInputRaw * BigInt.from(selectedUtxos.length); + final fee = baseFeeRaw + priorityFee.raw; final totalSpend = spendAmount + fee; return totalValue - totalSpend; diff --git a/lib/spectre/wallet_service/send_tx.dart b/lib/spectre/wallet_service/send_tx.dart index cfb762e..5b2efcc 100644 --- a/lib/spectre/wallet_service/send_tx.dart +++ b/lib/spectre/wallet_service/send_tx.dart @@ -11,14 +11,21 @@ class SendTx with _$SendTx { factory SendTx({ required SpectreUri uri, - required BigInt amountRaw, - required BigInt fee, required Transaction tx, + required List utxos, + @Default(false) bool userSelected, + required Amount amount, + required Amount baseFee, + required Amount priorityFee, + required Amount change, Address? changeAddress, String? note, + required BigInt mass, }) = _SendTx; - Amount get amount => Amount.raw(amountRaw); + Amount get fee => Amount.raw(baseFee.raw + priorityFee.raw); Address get toAddress => uri.address; + + List? get userSelectedUtxos => userSelected ? utxos : null; } diff --git a/lib/spectre/wallet_service/wallet_service.dart b/lib/spectre/wallet_service/wallet_service.dart index fc84778..ce995ed 100644 --- a/lib/spectre/wallet_service/wallet_service.dart +++ b/lib/spectre/wallet_service/wallet_service.dart @@ -2,6 +2,7 @@ import 'dart:async'; import '../spectre_client.dart'; import '../transaction.dart'; +import '../transaction/mass_calculator.dart'; import '../types.dart'; import 'send_tx.dart'; import 'signer_base.dart'; @@ -17,32 +18,49 @@ class WalletService { SendTx createSendTx({ required Address toAddress, - required BigInt amountRaw, + required Amount amount, required List spendableUtxos, + List? selectedUtxos, required BigInt feePerInput, + Amount? priorityFee, required Address changeAddress, String? note, }) { final txBuilder = TransactionBuilder( utxos: spendableUtxos, feePerInput: feePerInput, + priorityFee: priorityFee, ); final tx = txBuilder.createUnsignedTransaction( toAddress: toAddress, - amount: Amount.raw(amountRaw), + amountRaw: amount.raw, changeAddress: changeAddress, + preselectedUtxos: selectedUtxos, + ); + + final massCalculator = MassCalculator( + massPerTxByte: 1, + massPerScriptPubKeyByte: 10, + massPerSigOp: 1000, + storageMassParameter: kStorageMassParameter, ); + final mass = massCalculator.calcTxOverallMass(tx: tx); + return SendTx( uri: SpectreUri( address: toAddress, - amount: Amount.raw(amountRaw), + amount: amount, ), - amountRaw: amountRaw, tx: tx, + utxos: txBuilder.selectedUtxos, + amount: amount, + change: txBuilder.change, changeAddress: txBuilder.changeAddress, - fee: txBuilder.fee, + baseFee: txBuilder.baseFee, + priorityFee: txBuilder.priorityFee, note: note, + mass: mass, ); } @@ -50,10 +68,12 @@ class WalletService { required Address compoundAddress, required List spendableUtxos, required BigInt feePerInput, + Amount? priorityFee, }) { final selectedUtxos = spendableUtxos.take(kMaxInputsPerTransaction).toList(); - final fee = BigInt.from(selectedUtxos.length) * feePerInput; + final fee = BigInt.from(selectedUtxos.length) * feePerInput + + (priorityFee?.raw ?? BigInt.zero); final selectedTotal = selectedUtxos.fold( BigInt.zero, (sum, utxo) => sum + utxo.utxoEntry.amount, @@ -62,20 +82,26 @@ class WalletService { return createSendTx( toAddress: compoundAddress, - amountRaw: amountRaw, - spendableUtxos: selectedUtxos, + amount: Amount.raw(amountRaw), + spendableUtxos: spendableUtxos, + selectedUtxos: selectedUtxos, feePerInput: feePerInput, + priorityFee: priorityFee, changeAddress: compoundAddress, ); } - Future sendTransaction(SendTx rawTx) async { - final tx = rawTx.tx; - + Future sendTransaction(Transaction tx, {bool rbf = false}) async { await _signTransaction(tx); - final txId = await client.submitTransaction(tx.toRpc()); + final rpcTx = tx.toRpc(); + + if (rbf) { + final result = await client.submitTransactionReplacement(rpcTx); + return result.transactionId; + } + final txId = await client.submitTransaction(rpcTx); return txId; } diff --git a/lib/transactions/transaction_card.dart b/lib/transactions/transaction_card.dart index 0447b70..5469e90 100644 --- a/lib/transactions/transaction_card.dart +++ b/lib/transactions/transaction_card.dart @@ -30,6 +30,7 @@ class TransactionCard extends ConsumerWidget { final addressNotifier = ref.watch(addressNotifierProvider.notifier); final note = ref.watch(txNoteProvider(tx.id)); + final sprSymbol = ref.watch(sprSymbolProvider); final output = tx.apiTx.outputs[item.outputIndex]; @@ -55,9 +56,11 @@ class TransactionCard extends ConsumerWidget { final date = formater.format(txDate); final txTypeIcon = switch (item.type) { - TxItemType.send => Icon(AppIcons.sent, color: theme.primary, size: 18), + TxItemType.send => Icon(AppIcons.sent, color: theme.text60, size: 18), TxItemType.receive => Icon(AppIcons.received, color: theme.primary, size: 18), + TxItemType.thisWallet => + Icon(Icons.swap_vert, color: theme.primary, size: 18), TxItemType.compound => Icon(Icons.refresh, color: theme.primary, size: 18), }; @@ -112,7 +115,7 @@ class TransactionCard extends ConsumerWidget { .copyWith(fontSize: AppFontSizes.small), ), TextSpan( - text: ' SPR', + text: ' ${sprSymbol}', style: styles.textStyleTransactionUnit .copyWith(fontSize: AppFontSizes.small), ), @@ -120,12 +123,13 @@ class TransactionCard extends ConsumerWidget { ), ), Text( - date, + item.pending ? l10n.txPending : date, textAlign: TextAlign.start, style: styles.textStyleTransactionType.copyWith( - fontWeight: FontWeight.w400, - fontSize: AppFontSizes.smallest, - color: theme.text60), + fontWeight: FontWeight.w400, + fontSize: AppFontSizes.smallest, + color: theme.text60, + ), ), ], ), @@ -142,7 +146,7 @@ class TransactionCard extends ConsumerWidget { ), Consumer(builder: (context, ref, _) { final txState = ref.watch( - txConfirmationStatusProvider(tx), + txConfirmationStatusProvider(item), ); return Container( margin: const EdgeInsetsDirectional.only( diff --git a/lib/transactions/transaction_details.dart b/lib/transactions/transaction_details.dart index 50207b4..d05b18a 100644 --- a/lib/transactions/transaction_details.dart +++ b/lib/transactions/transaction_details.dart @@ -35,11 +35,14 @@ class TransactionDetails extends ConsumerWidget { final title = switch (txItem.type) { TxItemType.send => l10n.sent.toUpperCase(), TxItemType.receive => l10n.received.toUpperCase(), + TxItemType.thisWallet => + l10n.thisWallet.replaceAll('#', '').toUpperCase(), TxItemType.compound => l10n.compoundUppercased, }; final addressTitle = switch (txItem.type) { TxItemType.send => l10n.toAddress.toUpperCase(), TxItemType.receive || + TxItemType.thisWallet || TxItemType.compound => l10n.walletAddress.toUpperCase(), }; @@ -61,6 +64,14 @@ class TransactionDetails extends ConsumerWidget { maxLines: 1, ), ), + if (txItem.pending) + Padding( + padding: const EdgeInsets.only(top: 10, bottom: 10), + child: Text( + l10n.txPendingMessage, + style: styles.textStyleAddressPrimary, + ), + ), const SizedBox(height: 10), AmountLabel(amount: amount), Container( diff --git a/lib/transactions/transaction_details_sheet.dart b/lib/transactions/transaction_details_sheet.dart index e9534f6..415dfb9 100644 --- a/lib/transactions/transaction_details_sheet.dart +++ b/lib/transactions/transaction_details_sheet.dart @@ -6,6 +6,7 @@ import '../app_providers.dart'; import '../app_router.dart'; import '../contacts/contact_add_sheet.dart'; import '../l10n/l10n.dart'; +import '../util/ui_util.dart'; import '../util/util.dart'; import '../widgets/buttons.dart'; import '../widgets/sheet_handle.dart'; @@ -21,13 +22,13 @@ class TransactionDetailsSheet extends ConsumerWidget { final TxItem? txItem; const TransactionDetailsSheet({ - Key? key, + super.key, required this.transactionId, required this.address, this.displayContactButton = false, this.displayAddressButton = true, this.txItem, - }) : super(key: key); + }); @override Widget build(BuildContext context, WidgetRef ref) { @@ -54,6 +55,19 @@ class TransactionDetailsSheet extends ConsumerWidget { openUrl(explorer.urlForTx(transactionId)); } + Future updateFee() async { + final txItem = this.txItem; + if (txItem == null) { + return; + } + UIUtil.showUpdateFeeFlow( + context, + ref: ref, + tx: txItem.tx, + address: address, + ); + } + return SafeArea( minimum: EdgeInsets.only( bottom: MediaQuery.of(context).size.height * 0.035, @@ -72,7 +86,13 @@ class TransactionDetailsSheet extends ConsumerWidget { mainAxisSize: MainAxisSize.min, children: [ const SizedBox(height: 20), - if (displayAddressButton) ...[ + if (txItem?.pending ?? false) ...[ + PrimaryButton( + title: l10n.feeUpdate, + onPressed: updateFee, + ), + const SizedBox(height: 16), + ] else if (displayAddressButton) ...[ Stack(children: [ PrimaryButton( title: l10n.viewAddress, diff --git a/lib/transactions/transaction_empty_list.dart b/lib/transactions/transaction_empty_list.dart index 5eb30e1..1244ee9 100644 --- a/lib/transactions/transaction_empty_list.dart +++ b/lib/transactions/transaction_empty_list.dart @@ -4,11 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'tx_welcome_card.dart'; class TransactionEmptyList extends ConsumerWidget { - final String tokenSymbol; - const TransactionEmptyList({ - Key? key, - required this.tokenSymbol, - }) : super(key: key); + const TransactionEmptyList({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { diff --git a/lib/transactions/transaction_notifier.dart b/lib/transactions/transaction_notifier.dart index 79f0808..17c191a 100644 --- a/lib/transactions/transaction_notifier.dart +++ b/lib/transactions/transaction_notifier.dart @@ -17,6 +17,8 @@ class TransactionNotifier extends SafeChangeNotifier { var loadedTxs = IList(); bool get hasMore => loadedTxs.length < cache.txCount; + var pendingTxs = IList(); + bool _loading = false; bool get loading => _loading; String? _lastLoadedTxId; @@ -26,6 +28,17 @@ class TransactionNotifier extends SafeChangeNotifier { TransactionNotifier({required this.cache}); + Future updatePendingTxs(Iterable pendingTxs) async { + if (pendingTxs.isEmpty) { + this.pendingTxs = this.pendingTxs.clear(); + } else { + final txs = await cache.txsForApiTxs(pendingTxs); + this.pendingTxs = txs.toIList(); + } + + notifyListeners(); + } + void addToMemcache(ApiTransaction tx) { // Don't cache coinbase transactions if (tx.inputs.isEmpty) { diff --git a/lib/transactions/transaction_providers.dart b/lib/transactions/transaction_providers.dart index 2d5720d..275758e 100644 --- a/lib/transactions/transaction_providers.dart +++ b/lib/transactions/transaction_providers.dart @@ -155,6 +155,13 @@ final txNotifierForWalletProvider = ChangeNotifierProvider.autoDispose } }); + // Update pending transactions + ref.listen(pendingTxsProvider, (_, next) { + if (next.asData?.value case final pendingTxs?) { + notifier.updatePendingTxs(pendingTxs); + } + }); + ref.onDispose(() { notifier.disposed = true; }); @@ -169,9 +176,13 @@ final txNotifierProvider = Provider.autoDispose((ref) { }); final txConfirmationStatusProvider = - Provider.autoDispose.family((ref, tx) { + Provider.autoDispose.family((ref, txItem) { final blueScore = ref.watch(virtualSelectedParentBlueScoreProvider); + final tx = txItem.tx; + if (txItem.pending) { + return TxState.pending(); + } final kNoConfirmations = BigInt.from(100); final txBlueScore = tx.apiTx.acceptingBlockBlueScore; diff --git a/lib/transactions/transaction_state_tag.dart b/lib/transactions/transaction_state_tag.dart index ca4667a..786d5e7 100644 --- a/lib/transactions/transaction_state_tag.dart +++ b/lib/transactions/transaction_state_tag.dart @@ -30,6 +30,10 @@ class TransactionStateTag extends ConsumerWidget { l10n.unknown, style: styles.tagText, ), + pending: () => Text( + l10n.txInMempool, + style: styles.tagText, + ), unconfirmed: () => Text( l10n.notAccepted, style: styles.tagText, diff --git a/lib/transactions/transaction_types.dart b/lib/transactions/transaction_types.dart index e031e2a..6301ee1 100644 --- a/lib/transactions/transaction_types.dart +++ b/lib/transactions/transaction_types.dart @@ -38,10 +38,35 @@ class Tx with _$Tx { String get id => apiTx.transactionId; + Amount get amount => Amount.raw(BigInt.from(apiTx.outputs.first.amount)); + + ({Amount baseFee, Amount priorityFee}) get fees { + final baseFee = kFeePerInput * BigInt.from(apiTx.inputs.length); + final totalInput = inputData.fold( + BigInt.zero, + (total, input) => total + BigInt.from(input?.amount ?? 0), + ); + final totalOutput = apiTx.outputs.fold( + BigInt.zero, + (total, output) => total + BigInt.from(output.amount), + ); + final totalFee = totalInput - totalOutput; + + var priorityFee = totalFee - baseFee; + if (priorityFee < BigInt.zero) { + priorityFee = BigInt.zero; + } + + return ( + baseFee: Amount.raw(baseFee), + priorityFee: Amount.raw(priorityFee), + ); + } + factory Tx.fromJson(Map json) => _$TxFromJson(json); } -enum TxItemType { send, receive, compound } +enum TxItemType { send, receive, compound, thisWallet } @Freezed(equal: false) class TxItem with _$TxItem { @@ -50,6 +75,7 @@ class TxItem with _$TxItem { required Tx tx, required int outputIndex, required TxItemType type, + @Default(false) bool pending, }) = _TxItem; @override @@ -70,10 +96,12 @@ class TxItem with _$TxItem { @freezed class TxListItem with _$TxListItem { TxListItem._(); + factory TxListItem.pendingTxItem(TxItem tx) = _TxListItemPendingTxItem; factory TxListItem.txItem(TxItem tx) = _TxListItemTxItem; factory TxListItem.loader(bool hasMore) = _TxListItemLoader; late final id = when( + pendingTxItem: (item) => '${item.tx.id}:${item.outputIndex}:${item.type}', txItem: (item) => '${item.tx.id}:${item.outputIndex}:${item.type}', loader: (_) => 'loader', ); @@ -82,6 +110,7 @@ class TxListItem with _$TxListItem { @freezed class TxState with _$TxState { const factory TxState.unknown() = _TxStateUnknown; + const factory TxState.pending() = _TxStatePending; const factory TxState.unconfirmed() = _TxStateUnconfirmed; const factory TxState.confirming(BigInt confirmations) = _TxStateConfirming; const factory TxState.confirmed() = _TxStateConfirmed; diff --git a/lib/transactions/transactions_widget.dart b/lib/transactions/transactions_widget.dart index c399ab1..55e4e20 100644 --- a/lib/transactions/transactions_widget.dart +++ b/lib/transactions/transactions_widget.dart @@ -9,19 +9,20 @@ import '../app_providers.dart'; import '../spectre/spectre.dart'; import '../l10n/l10n.dart'; import '../settings/tx_settings.dart'; +import '../utxos/utxos_notifier.dart'; import '../wallet/wallet_types.dart'; +import '../wallet_address/wallet_address_notifier.dart'; import 'transaction_card.dart'; import 'transaction_empty_list.dart'; import 'transaction_types.dart'; -final _txListItemsProvider = - Provider.autoDispose.family, WalletInfo>((ref, wallet) { - final addressNotifier = ref.watch(addressNotifierProvider.notifier); - final utxoNotifier = ref.watch(utxoNotifierProvider.notifier); - final txNotifier = ref.watch(txNotifierForWalletProvider(wallet)); - final txFilter = ref.watch(txFilterProvider); - - final txItems = txNotifier.loadedTxs.expand((tx) { +List _txListItemsFromTxs( + Iterable txs, { + required TxFilter txFilter, + required WalletAddressNotifier addressNotifier, + required UtxosNotifier utxoNotifier, +}) { + return txs.expand((tx) { if (txFilter == TxFilter.hideNotAcceptedCoinbase && tx.apiTx.inputs.isEmpty && !tx.apiTx.isAccepted) { @@ -65,6 +66,15 @@ final _txListItemsProvider = outputs.last == output) { continue; } + if (addressNotifier.containsAddress(address) && hasWalletInputs) { + final listItem = TxListItem.txItem(TxItem( + tx: tx, + outputIndex: output.index, + type: TxItemType.thisWallet, + )); + listItems.add(listItem); + continue; + } if (addressNotifier.containsAddress(address)) { final listItem = TxListItem.txItem(TxItem( tx: tx, @@ -84,17 +94,41 @@ final _txListItemsProvider = } return listItems; - }); + }).toList(growable: false); +} - return [...txItems, TxListItem.loader(txNotifier.hasMore)]; +final _txListItemsProvider = + Provider.autoDispose.family, WalletInfo>((ref, wallet) { + final addressNotifier = ref.watch(addressNotifierProvider.notifier); + final utxoNotifier = ref.watch(utxoNotifierProvider.notifier); + final txNotifier = ref.watch(txNotifierForWalletProvider(wallet)); + final txFilter = ref.watch(txFilterProvider); + + final pendingItems = _txListItemsFromTxs( + txNotifier.pendingTxs, + txFilter: txFilter, + addressNotifier: addressNotifier, + utxoNotifier: utxoNotifier, + ) + .map((item) => item.maybeWhen( + txItem: (txItem) => TxListItem.pendingTxItem( + txItem.copyWith(pending: true), + ), + orElse: () => item, + )) + .toList(growable: false); + + final txItems = _txListItemsFromTxs(txNotifier.loadedTxs, + txFilter: txFilter, + addressNotifier: addressNotifier, + utxoNotifier: utxoNotifier); + + return [...pendingItems, ...txItems, TxListItem.loader(txNotifier.hasMore)]; }); class TransactionsWidget extends ConsumerWidget { - final String tokenSymbol; - const TransactionsWidget({ Key? key, - this.tokenSymbol = 'SPR', }) : super(key: key); @override @@ -157,7 +191,7 @@ class TransactionsWidget extends ConsumerWidget { backgroundColor: theme.backgroundDark, onRefresh: refresh, child: !txNotifier.loading && items.length == 1 - ? TransactionEmptyList(tokenSymbol: tokenSymbol) + ? const TransactionEmptyList() : AutomaticAnimatedList( key: PageStorageKey(wallet), physics: AlwaysScrollableScrollPhysics(), @@ -168,6 +202,7 @@ class TransactionsWidget extends ConsumerWidget { items: items, itemBuilder: (context, item, animation) { final card = item.when( + pendingTxItem: (item) => TransactionCard(item: item), txItem: (item) => TransactionCard(item: item), loader: (hasMore) { if (!hasMore) return const SizedBox(); diff --git a/lib/transactions/tx_cache_service.dart b/lib/transactions/tx_cache_service.dart index 3afec47..aec106c 100644 --- a/lib/transactions/tx_cache_service.dart +++ b/lib/transactions/tx_cache_service.dart @@ -99,7 +99,7 @@ class TxCacheService { return tx; } - Future> _txsForApiTxs(Iterable apiTxs) async { + Future> txsForApiTxs(Iterable apiTxs) async { await _cacheInputsFor(apiTxs); final txs = apiTxs.map(_txForApiTx); @@ -110,7 +110,7 @@ class TxCacheService { Future> cacheWalletTxs(Iterable apiTxs) async { memCache.addEntries(apiTxs.map((e) => MapEntry(e.transactionId, e))); - final txs = (await _txsForApiTxs(apiTxs)).toList(); + final txs = (await txsForApiTxs(apiTxs)).toList(); final txIndexes = txs.map( (tx) => TxIndex( diff --git a/lib/tx_report/tx_report_dialog.dart b/lib/tx_report/tx_report_dialog.dart index d79f002..ce9d5b8 100644 --- a/lib/tx_report/tx_report_dialog.dart +++ b/lib/tx_report/tx_report_dialog.dart @@ -43,6 +43,7 @@ class DownloadTxsDialog extends HookConsumerWidget { final txNotifier = ref.read(txNotifierProvider); final txNotes = ref.read(txNotesProvider); final addresses = ref.read(addressNotifierProvider); + final sprSymbol = ref.read(sprSymbolProvider); try { if (options.refreshTxs) { @@ -94,7 +95,7 @@ class DownloadTxsDialog extends HookConsumerWidget { return null; } - return getCsvForItem(item); + return getCsvForItem(item, sprSymbol); }) .whereNotNull() .toIList(); diff --git a/lib/tx_report/tx_report_util.dart b/lib/tx_report/tx_report_util.dart index d644358..55cb76e 100644 --- a/lib/tx_report/tx_report_util.dart +++ b/lib/tx_report/tx_report_util.dart @@ -27,7 +27,7 @@ String getCsvHeader(AppLocalizations l10n) { return header; } -String getCsvForItem(TxReportItem item) { +String getCsvForItem(TxReportItem item, String sprSymbol) { final txDate = DateTime.fromMillisecondsSinceEpoch(item.timestamp); final formater = DateFormat('yyyy-MM-dd HH:mm:ss'); final date = formater.format(txDate); @@ -35,11 +35,11 @@ String getCsvForItem(TxReportItem item) { final line = [ date, item.sendAmount, - 'SPR', + sprSymbol, item.receiveAmount, - 'SPR', + sprSymbol, item.fee, - 'SPR', + sprSymbol, item.label, item.description, item.txHash, diff --git a/lib/util/formatters/currency_formatter.dart b/lib/util/formatters/currency_formatter.dart index ab48f34..0f0683a 100644 --- a/lib/util/formatters/currency_formatter.dart +++ b/lib/util/formatters/currency_formatter.dart @@ -1,9 +1,9 @@ import 'dart:math'; import 'package:decimal/decimal.dart'; +import 'package:decimal/intl.dart'; import 'package:flutter/services.dart'; import 'package:intl/intl.dart'; -import 'package:decimal/intl.dart'; import '../numberutil.dart'; @@ -37,8 +37,7 @@ class CurrencyFormatter extends TextInputFormatter { String _formatNumber(String numberStr) { final number = Decimal.tryParse(numberStr); if (number != null) { - final formatter = DecimalFormatter(numberFormat); // Use DecimalFormatter - return formatter.format(number); // Format the Decimal number + return numberFormat.format(DecimalIntl(number)); } return numberStr; } diff --git a/lib/util/hapticutil.dart b/lib/util/hapticutil.dart index aa8d2b8..69944ba 100644 --- a/lib/util/hapticutil.dart +++ b/lib/util/hapticutil.dart @@ -1,6 +1,6 @@ import 'package:device_info_plus/device_info_plus.dart'; import 'package:flutter/services.dart'; -import 'package:vibration/vibration.dart'; +import 'package:flutter_vibrate/flutter_vibrate.dart'; import 'platform.dart'; @@ -9,7 +9,7 @@ class HapticUtil { const HapticUtil(); /// Return true if this device supports taptic engine (iPhone 7+) - Future hasTapticEngine() async { + Future hasTapicEngine() async { if (!kPlatformIsIOS) { return false; } @@ -35,9 +35,9 @@ class HapticUtil { /// Feedback for error Future error() async { if (kPlatformIsIOS) { - // If this is a simulator or the device doesn't have a taptic engine - if (await hasTapticEngine() && await Vibration.hasVibrator() == true) { - Vibration.vibrate(duration: 500); // Vibration for error feedback + // If this is simulator or this device doesnt have tapic then we can't use this + if (await hasTapicEngine() && await Vibrate.canVibrate) { + Vibrate.feedback(FeedbackType.error); } else { HapticFeedback.vibrate(); } @@ -49,10 +49,9 @@ class HapticUtil { /// Feedback for success Future success() async { if (kPlatformIsIOS) { - // If this is a simulator or the device doesn't have a taptic engine - if (await hasTapticEngine() && await Vibration.hasVibrator() == true) { - Vibration.vibrate( - duration: 100); // Short vibration for success feedback + // If this is simulator or this device doesnt have tapic then we can't use this + if (await hasTapicEngine() && await Vibrate.canVibrate) { + Vibrate.feedback(FeedbackType.medium); } else { HapticFeedback.mediumImpact(); } diff --git a/lib/util/numberutil.dart b/lib/util/numberutil.dart index 8bb66f2..ae1f0f6 100644 --- a/lib/util/numberutil.dart +++ b/lib/util/numberutil.dart @@ -37,8 +37,7 @@ class NumberUtil { symbol: '', decimalDigits: scale, ); - final decimalFormatter = DecimalFormatter(formatter); - final formated = decimalFormatter.format(value).trim(); + final formated = formatter.format(DecimalIntl(value)).trim(); return formated; } diff --git a/lib/util/ui_util.dart b/lib/util/ui_util.dart index 7ac21ab..f692f29 100644 --- a/lib/util/ui_util.dart +++ b/lib/util/ui_util.dart @@ -3,10 +3,18 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:oktoast/oktoast.dart'; import '../app_providers.dart'; +import '../app_router.dart'; import '../spectre/spectre.dart'; +import '../spectre/transaction/mass_calculator.dart'; import '../l10n/l10n.dart'; +import '../send_sheet/fee_sheet.dart'; +import '../send_sheet/send_complete_sheet.dart'; import '../send_sheet/send_confirm_sheet.dart'; import '../send_sheet/send_sheet.dart'; +import '../transactions/transaction_types.dart'; +import '../widgets/app_simpledialog.dart'; +import '../widgets/dialog.dart'; +import '../widgets/pending_tx_dialog.dart'; import '../widgets/sheet_util.dart'; import '../widgets/toast_widget.dart'; import 'numberutil.dart'; @@ -25,20 +33,240 @@ abstract class UIUtil { ); } + static Future showUpdateFeeFlow( + BuildContext context, { + required WidgetRef ref, + required Tx tx, + required String address, + }) async { + final theme = ref.read(themeProvider); + final l10n = l10nOf(context); + + try { + final walletService = ref.read(walletServiceProvider); + final notes = ref.read(txNotesProvider); + final notifier = ref.read(addressNotifierProvider); + final spendableUtxos = ref.read(spendableUtxosProvider); + + final amount = Amount.raw(BigInt.from(tx.apiTx.outputs.first.amount)); + final fees = tx.fees; + + final changeAddress = await notifier.nextChangeAddress; + + final toAddress = Address.tryParse( + address, + expectedPrefix: changeAddress.address.prefix, + ); + + if (toAddress == null) { + UIUtil.showSnackbar(l10n.feeUpdateAddressError, context); + return; + } + + final txBuilder = TransactionBuilder( + utxos: spendableUtxos, + feePerInput: kFeePerInput, + priorityFee: fees.priorityFee, + ); + final newTx = txBuilder.rebuildTransaction( + tx.apiTx, + toAddress: toAddress, + changeAddress: changeAddress.address, + ); + + if (newTx == null) { + UIUtil.showSnackbar(l10n.feeUpdateRebuildError, context); + return; + } + + final massCalculator = MassCalculator( + massPerTxByte: 1, + massPerScriptPubKeyByte: 10, + massPerSigOp: 1000, + storageMassParameter: kStorageMassParameter, + ); + + final mass = massCalculator.calcTxOverallMass(tx: newTx); + + final priorityFee = Amount.raw(fees.priorityFee.raw + BigInt.one); + final newPriorityFee = await Sheets.showAppHeightNineSheet( + context: context, + widget: FeeSheet( + baseFee: fees.baseFee, + priorityFee: priorityFee, + txMass: mass, + rbf: true, + ), + theme: theme, + ); + + if (newPriorityFee == null) { + // cancelled + return; + } + + Transaction? replacementTx; + if (tx.apiTx.outputs.length == 1 && + tx.apiTx.outputs.first.scriptPublicKeyAddress == + changeAddress.address.encoded) { + // compound tx + final sendTx = walletService.createCompoundTx( + compoundAddress: changeAddress.address, + spendableUtxos: spendableUtxos, + feePerInput: kFeePerInput, + priorityFee: newPriorityFee, + ); + replacementTx = sendTx.tx; + } else { + final newTxBuilder = TransactionBuilder( + utxos: spendableUtxos, + feePerInput: kFeePerInput, + priorityFee: newPriorityFee, + ); + replacementTx = newTxBuilder.rebuildTransaction( + tx.apiTx, + toAddress: toAddress, + changeAddress: changeAddress.address, + ); + } + + if (replacementTx == null) { + UIUtil.showSnackbar(l10n.feeUpdateRebuildError2, context); + return; + } + + final note = notes.getNoteForTxId(tx.id); + + // Authenticate + final walletAuth = ref.read(walletAuthProvider.notifier); + final authUtil = ref.read(authUtilProvider); + bool auth = false; + if (walletAuth.needsPasswordAuth) { + auth = await authUtil.authenticateWithPassword( + context, + validator: (password) => walletAuth.unlock(password: password), + ); + } else { + final symbol = ref.read(symbolProvider(amount)); + final formatedAmount = NumberUtil.formatedAmount(amount); + final message = '${l10n.sendConfirm} $formatedAmount $symbol'; + auth = await authUtil.authenticate(context, message, message); + } + if (!auth) { + return; + } + + try { + AppDialogs.showInProgressDialog( + context, + l10n.feeUpdateTitle, + l10n.sendTxProgressDescription, + ); + + final txId = await walletService.sendTransaction( + replacementTx, + rbf: true, + ); + + ref.invalidate(pendingTxsProvider); + + if (note != null) { + notes.addNoteForTxId(txId, note.note); + } + + final sheet = SendCompleteSheet( + amount: amount, + toAddress: toAddress, + txId: txId, + note: note?.note, + ); + + Sheets.showAppHeightNineSheet( + context: context, + theme: theme, + closeOnTap: true, + removeUntilHome: true, + widget: sheet, + ); + } catch (e) { + UIUtil.showSnackbar(l10n.feeUpdateError, context); + appRouter.pop(context); + } + } catch (e) { + UIUtil.showSnackbar(l10n.feeUpdateError, context); + } + } + + static Future<({bool cont, bool rbf})> checkForPendingTx( + BuildContext context, { + required WidgetRef ref, + }) async { + final txNotifier = ref.read(txNotifierProvider); + final pendingTxs = txNotifier.pendingTxs; + + bool rbf = false; + if (pendingTxs.isNotEmpty) { + rbf = await showAppDialog( + context: context, + builder: (_) => PendingTxDialog( + pendingTx: pendingTxs.first, + safeContext: context, + safeRef: ref, + ), + ) ?? + false; + if (rbf == false) { + return (cont: false, rbf: rbf); + } + } + + return (cont: true, rbf: rbf); + } + + // static Future checkForPendingTx( + // BuildContext context, { + // required WidgetRef ref, + // required void Function(bool rbf) onContinue, + // }) async { + // final txNotifier = ref.read(txNotifierProvider); + // final pendingTxs = txNotifier.pendingTxs; + + // bool rbf = false; + // if (pendingTxs.isNotEmpty) { + // rbf = await showAppDialog( + // context: context, + // builder: (_) => PendingTxDialog(pendingTx: pendingTxs.first), + // ) ?? + // false; + // if (rbf == false) { + // return; + // } + // } + + // onContinue(rbf); + // } + static Future showSendFlow( BuildContext context, { required WidgetRef ref, required SpectreUri uri, + bool useRbf = false, }) async { final theme = ref.read(themeProvider); final amount = uri.amount; if (amount == null) { - Sheets.showAppHeightNineSheet( - context: context, - theme: theme, - widget: SendSheet(uri: uri), - ); + final (:cont, :rbf) = useRbf + ? (cont: true, rbf: true) + : await UIUtil.checkForPendingTx(context, ref: ref); + if (cont) { + Sheets.showAppHeightNineSheet( + context: context, + theme: theme, + widget: SendSheet(uri: uri, rbf: rbf), + ); + } + return; } @@ -46,13 +274,29 @@ abstract class UIUtil { final walletService = ref.read(walletServiceProvider); final addressNotifier = ref.read(addressNotifierProvider); + final (:cont, :rbf) = useRbf + ? (cont: true, rbf: true) + : await UIUtil.checkForPendingTx(context, ref: ref); + + if (!cont) { + return; + } + try { final changeAddress = await addressNotifier.nextChangeAddress; - final tx = walletService.createSendTx( + + Amount? priorityFee; + if (rbf) { + final pendingTx = ref.read(txNotifierProvider).pendingTxs.first; + priorityFee = Amount.raw(pendingTx.fees.priorityFee.raw + BigInt.one); + } + + final sendTx = walletService.createSendTx( toAddress: uri.address, - amountRaw: amount.raw, + amount: amount, spendableUtxos: spendableUtxos, feePerInput: kFeePerInput, + priorityFee: priorityFee, changeAddress: changeAddress.address, note: uri.message, ); @@ -60,7 +304,7 @@ abstract class UIUtil { Sheets.showAppHeightNineSheet( context: context, theme: theme, - widget: SendConfirmSheet(tx: tx), + widget: SendConfirmSheet(sendTx: sendTx, rbf: rbf), ); } catch (e) { UIUtil.showSnackbar(e.toString(), context); @@ -77,18 +321,19 @@ abstract class UIUtil { required BuildContext context, required String action, required Amount amount, + required String symbol, BigInt? fee, }) { final l10n = l10nOf(context); if (amount.raw != BigInt.zero) { final amountStr = NumberUtil.formatedAmount(amount); - final amountConfirm = l10n.amountConfirm(amountStr, amount.symbolLabel); + final amountConfirm = l10n.amountConfirm(amountStr, symbol); action += '\n$amountConfirm'; } if (fee != null && fee != BigInt.zero) { final spectre = TokenInfo.spectre; final feeStr = NumberUtil.approxAmountRaw(fee, spectre.decimals); - final feeConfirm = l10n.feeConfirm(feeStr, spectre.symbolLabel); + final feeConfirm = l10n.feeConfirm(feeStr, symbol); action += '\n$feeConfirm'; } return action; diff --git a/lib/utxos/utxo_card.dart b/lib/utxos/utxo_card.dart index c539749..2aab121 100644 --- a/lib/utxos/utxo_card.dart +++ b/lib/utxos/utxo_card.dart @@ -11,10 +11,12 @@ import '../widgets/sheet_util.dart'; class UtxoCard extends ConsumerWidget { final Utxo item; + final bool selectable; const UtxoCard({ Key? key, required this.item, + this.selectable = false, }) : super(key: key); @override @@ -24,9 +26,11 @@ class UtxoCard extends ConsumerWidget { final styles = ref.watch(stylesProvider); final amount = Amount.raw(item.utxoEntry.amount); + final sprSymbol = ref.watch(sprSymbolProvider); final formatedValue = NumberUtil.formatedAmount(amount); final fiatValue = ref.watch(formatedFiatForAmountProvider(amount)); + final addressNotifier = ref.watch(addressNotifierProvider); void showTxDetails() { Sheets.showAppHeightEightSheet( @@ -39,6 +43,29 @@ class UtxoCard extends ConsumerWidget { ); } + void updateSelected(bool? value) { + if (value == null) { + return; + } + + final notifier = ref.read(selectedUtxosProvider.notifier); + if (value) { + notifier.update((state) => state.add(item)); + } else { + notifier.update((state) => state.remove(item)); + } + } + + void onPressed() { + if (selectable) { + final selectedUtxos = ref.read(selectedUtxosProvider); + final isSelected = selectedUtxos.contains(item); + updateSelected(!isSelected); + } else { + showTxDetails(); + } + } + return Container( margin: EdgeInsetsDirectional.fromSTEB(14, 4, 14, 4), decoration: BoxDecoration( @@ -48,12 +75,21 @@ class UtxoCard extends ConsumerWidget { ), child: TextButton( style: styles.cardButtonStyle, - onPressed: showTxDetails, + onPressed: onPressed, child: Padding( padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 12), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ + if (selectable) + Consumer(builder: (context, ref, child) { + final selected = ref.watch( + selectedUtxosProvider.select( + (value) => value.contains(item), + ), + ); + return Checkbox(value: selected, onChanged: updateSelected); + }), Flexible( flex: 1, child: Row( @@ -66,7 +102,8 @@ class UtxoCard extends ConsumerWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - l10n.address, + addressNotifier.nameForAddress(item.address) ?? + l10n.address, style: styles.textStyleTransactionAmountSmall, ), Text( @@ -100,8 +137,7 @@ class UtxoCard extends ConsumerWidget { style: styles.textStyleCurrencyAlt, ), TextSpan( - text: - ' ${TokenInfo.spectre.symbolLabel}', + text: ' $sprSymbol', style: styles.textStyleCurrencyAlt, ), ], diff --git a/lib/utxos/utxos_providers.dart b/lib/utxos/utxos_providers.dart index 299680a..6fcd418 100644 --- a/lib/utxos/utxos_providers.dart +++ b/lib/utxos/utxos_providers.dart @@ -1,3 +1,4 @@ +import 'package:fast_immutable_collections/fast_immutable_collections.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../core/core_providers.dart'; @@ -86,3 +87,5 @@ final spendableUtxosProvider = Provider.autoDispose((ref) { return spendableUtxos; }); + +final selectedUtxosProvider = StateProvider>((ref) => ISet()); diff --git a/lib/utxos/utxos_selection_page.dart b/lib/utxos/utxos_selection_page.dart new file mode 100644 index 0000000..5832a4e --- /dev/null +++ b/lib/utxos/utxos_selection_page.dart @@ -0,0 +1,196 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import '../app_providers.dart'; +import '../app_router.dart'; +import '../spectre/spectre.dart'; +import '../l10n/l10n.dart'; +import '../util/ui_util.dart'; +import '../widgets/buttons.dart'; +import '../widgets/sheet_widget.dart'; +import 'utxos_widget.dart'; + +final selectionSummaryProvider = + Provider.family.autoDispose((ref, state) { + final spendableUtxos = ref.watch(spendableUtxosProvider); + final selectedUtxos = ref.watch(selectedUtxosProvider).toList(); + + final tx = state.$1; + final changeAddress = state.$2; + + final totalAmountRaw = selectedUtxos.fold( + BigInt.zero, + (total, utxo) => total + utxo.utxoEntry.amount, + ); + final txBuilder = TransactionBuilder( + utxos: spendableUtxos, + priorityFee: tx.priorityFee, + ); + + var amountRaw = totalAmountRaw - + tx.priorityFee.raw - + kFeePerInput * BigInt.from(selectedUtxos.length); + if (amountRaw > tx.amount.raw) { + amountRaw = tx.amount.raw; + } + try { + final newTx = txBuilder.createUnsignedTransaction( + toAddress: tx.toAddress, + amountRaw: amountRaw, + changeAddress: changeAddress, + preselectedUtxos: selectedUtxos, + ); + + final newSendTx = tx.copyWith( + amount: Amount.raw(totalAmountRaw), + tx: newTx, + utxos: txBuilder.selectedUtxos, + change: txBuilder.change, + baseFee: txBuilder.baseFee, + priorityFee: txBuilder.priorityFee, + ); + + return newSendTx; + } catch (e) { + rethrow; + } +}); + +class UtxosSelectionSummary extends HookConsumerWidget { + final SendTx tx; + const UtxosSelectionSummary({super.key, required this.tx}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final styles = ref.watch(stylesProvider); + + final pendingTx = + ref.watch(selectionSummaryProvider((tx, tx.changeAddress!))); + + final spendableUtxos = ref.watch(spendableUtxosProvider); + final symbol = ref.watch(sprSymbolProvider); + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Target Amount', + style: styles.textStyleParagraph, + ), + Text( + 'Selected Amount', + style: styles.textStyleParagraph, + ), + Text( + 'Selected UTXOs', + style: styles.textStyleParagraph, + ), + Text( + 'Total fee', + style: styles.textStyleParagraph, + ), + Text( + 'Change', + style: styles.textStyleParagraph, + ), + ], + ), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + '${tx.amount.value} $symbol', + style: styles.textStyleParagraph, + ), + Text( + '${pendingTx.amount.value} $symbol', + style: styles.textStyleParagraph, + ), + Text( + '${pendingTx.utxos.length} of ${spendableUtxos.length}', + style: styles.textStyleParagraph, + ), + Text( + '${pendingTx.fee.value} $symbol', + style: styles.textStyleParagraph, + ), + Text( + '${pendingTx.change.value}$symbol', + style: styles.textStyleParagraph, + ), + ], + ), + ], + ), + ); + } +} + +class UtxosSelectionPage extends HookConsumerWidget { + final SendTx tx; + const UtxosSelectionPage({ + super.key, + required this.tx, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final l10n = l10nOf(context); + + Future onConfirm() async { + try { + final addressNotifier = ref.read(addressNotifierProvider); + final changeAddress = await addressNotifier.nextChangeAddress; + final spendableUtxos = ref.read(spendableUtxosProvider); + final selectedUtxos = ref.read(selectedUtxosProvider).toList(); + + final txBuilder = TransactionBuilder( + utxos: spendableUtxos, + feePerInput: kFeePerInput, + priorityFee: tx.priorityFee, + ); + txBuilder.createUnsignedTransaction( + toAddress: tx.toAddress, + amountRaw: tx.amount.raw, + preselectedUtxos: selectedUtxos, + changeAddress: changeAddress.address, + ); + appRouter.pop(context, withResult: selectedUtxos); + } catch (e) { + UIUtil.showSnackbar(l10n.utxoSelectionHint, context); + } + } + + return SheetWidget( + title: l10n.utxoSelectionTitle, + mainWidget: Column( + mainAxisSize: MainAxisSize.min, + children: [ + UtxosSelectionSummary(tx: tx), + Expanded(child: UtxosWidget(selectionMode: true)), + ], + ), + bottomWidget: Padding( + padding: const EdgeInsets.symmetric(horizontal: 28), + child: Column( + children: [ + PrimaryButton( + title: l10n.confirm, + onPressed: onConfirm, + ), + const SizedBox(height: 16), + PrimaryOutlineButton( + title: l10n.cancel, + onPressed: () => appRouter.pop(context), + ), + ], + ), + ), + ); + } +} diff --git a/lib/utxos/utxos_widget.dart b/lib/utxos/utxos_widget.dart index 1a371be..6b83ba7 100644 --- a/lib/utxos/utxos_widget.dart +++ b/lib/utxos/utxos_widget.dart @@ -6,7 +6,12 @@ import 'utxo_card.dart'; import 'utxos_empty_card.dart'; class UtxosWidget extends ConsumerWidget { - const UtxosWidget({Key? key}) : super(key: key); + final bool selectionMode; + + const UtxosWidget({ + super.key, + this.selectionMode = false, + }); @override Widget build(BuildContext context, WidgetRef ref) { @@ -42,7 +47,7 @@ class UtxosWidget extends ConsumerWidget { itemCount: utxoList.length, itemBuilder: (context, index) { final item = utxoList[index]; - return UtxoCard(item: item); + return UtxoCard(item: item, selectable: selectionMode); }, ), ); diff --git a/lib/wallet_address/address_details_sheet.dart b/lib/wallet_address/address_details_sheet.dart index 80331bd..257a8d8 100644 --- a/lib/wallet_address/address_details_sheet.dart +++ b/lib/wallet_address/address_details_sheet.dart @@ -16,7 +16,6 @@ import '../widgets/app_text_field.dart'; import '../widgets/buttons.dart'; import '../widgets/contact_info_button.dart'; import '../widgets/sheet_widget.dart'; -import '../widgets/tap_outside_unfocus.dart'; import 'wallet_address.dart'; class AddressDetailsSheet extends HookConsumerWidget { @@ -65,72 +64,70 @@ class AddressDetailsSheet extends HookConsumerWidget { }; }, const []); - return TapOutsideUnfocus( - child: SheetWidget( - title: title, - rightWidget: ContactInfoButton(onPressed: showExplorer), - mainWidget: Column( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - AppTextField( - topMargin: 10, - controller: nameController, - focusNode: nameFocusNode, - textInputAction: TextInputAction.done, - autocorrect: false, - keyboardType: TextInputType.text, - inputFormatters: [ - LengthLimitingTextInputFormatter(25), - ], - style: styles.textStyleAppTextField, - ), - const SizedBox(height: 22), - AddressThreeLineText( - address: address.encoded, - type: AddressTextType.PRIMARY60, - ), - const SizedBox(height: 12), - if (address.type == AddressType.receive) - Expanded( - child: Center( - child: Container( - constraints: BoxConstraints(maxWidth: 280), - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(10), - shape: BoxShape.rectangle, - border: Border.all(color: theme.primary, width: 2), - ), - child: QrImageView( - data: '${address.encoded}', - gapless: false, - embeddedImage: AssetImage('assets/qr_code_icon.png'), - embeddedImageStyle: QrEmbeddedImageStyle( - size: const Size(40, 40), - ), - backgroundColor: Colors.white, - errorCorrectionLevel: QrErrorCorrectLevel.Q, - semanticsLabel: 'QR code for address $address', + return SheetWidget( + title: title, + rightWidget: ContactInfoButton(onPressed: showExplorer), + mainWidget: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + AppTextField( + topMargin: 10, + controller: nameController, + focusNode: nameFocusNode, + textInputAction: TextInputAction.done, + autocorrect: false, + keyboardType: TextInputType.text, + inputFormatters: [ + LengthLimitingTextInputFormatter(25), + ], + style: styles.textStyleAppTextField, + ), + const SizedBox(height: 22), + AddressThreeLineText( + address: address.encoded, + type: AddressTextType.PRIMARY60, + ), + const SizedBox(height: 12), + if (address.type == AddressType.receive) + Expanded( + child: Center( + child: Container( + constraints: BoxConstraints(maxWidth: 280), + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10), + shape: BoxShape.rectangle, + border: Border.all(color: theme.primary, width: 2), + ), + child: QrImageView( + data: '${address.encoded}', + gapless: false, + embeddedImage: AssetImage('assets/qr_code_icon.png'), + embeddedImageStyle: QrEmbeddedImageStyle( + size: const Size(40, 40), ), + backgroundColor: Colors.white, + errorCorrectionLevel: QrErrorCorrectLevel.Q, + semanticsLabel: 'QR code for address $address', ), ), ), - ], - ), - bottomWidget: Padding( - padding: const EdgeInsets.symmetric(horizontal: 28), - child: Column(children: [ - PrimaryButton( - title: l10n.copyAddress, - onPressed: copyAddress, - ), - const SizedBox(height: 16), - PrimaryOutlineButton( - title: l10n.close, - onPressed: () => appRouter.pop(context), ), - ]), - ), + ], + ), + bottomWidget: Padding( + padding: const EdgeInsets.symmetric(horizontal: 28), + child: Column(children: [ + PrimaryButton( + title: l10n.copyAddress, + onPressed: copyAddress, + ), + const SizedBox(height: 16), + PrimaryOutlineButton( + title: l10n.close, + onPressed: () => appRouter.pop(context), + ), + ]), ), ); } diff --git a/lib/wallet_balance/wallet_balance_providers.dart b/lib/wallet_balance/wallet_balance_providers.dart index d8a4f83..6d947e4 100644 --- a/lib/wallet_balance/wallet_balance_providers.dart +++ b/lib/wallet_balance/wallet_balance_providers.dart @@ -6,6 +6,7 @@ import 'package:intl/intl.dart'; import '../coingecko/coingecko_providers.dart'; import '../core/core_providers.dart'; import '../spectre/spectre.dart'; +import '../settings/available_currency.dart'; import '../settings/settings_providers.dart'; import '../util/formatters.dart'; import '../util/numberutil.dart'; @@ -126,13 +127,13 @@ final formatedTotalFiatProvider = Provider.autoDispose((ref) { decimalDigits: decimals, ); - final decimalFormatter = DecimalFormatter(formatter); - return decimalFormatter.format(fiat); + return formatter.format(DecimalIntl(fiat)); }); final formatedSpectrePriceProvider = Provider.autoDispose((ref) { final price = ref.watch(spectrePriceProvider).price; final currency = ref.watch(currencyProvider); + final symbol = ref.watch(sprSymbolProvider); final decimals = price >= Decimal.parse('1') ? 2 : price >= Decimal.parse('0.01') @@ -140,16 +141,13 @@ final formatedSpectrePriceProvider = Provider.autoDispose((ref) { : price >= Decimal.parse('0.0001') ? 6 : 8; - - final formatter = NumberFormat.currency( + final priceStr = NumberFormat.currency( symbol: currency.symbol, name: currency.name, decimalDigits: decimals, - ); - final decimalFormatter = DecimalFormatter(formatter); - final priceStr = decimalFormatter.format(price); + ).format(DecimalIntl(price)); - return '$priceStr / SPR'; + return '$priceStr / $symbol'; }); final fiatValueForAddressProvider = @@ -165,12 +163,10 @@ final formatedFiatForAddressProvider = final balance = ref.watch(fiatValueForAddressProvider(address)); final currency = ref.watch(currencyProvider); - final formatter = NumberFormat.currency( + return NumberFormat.currency( symbol: currency.symbol, name: currency.name, - ); - final decimalFormatter = DecimalFormatter(formatter); - return decimalFormatter.format(balance); + ).format(DecimalIntl(balance)); }); final formatedFiatForAmountProvider = @@ -179,13 +175,10 @@ final formatedFiatForAmountProvider = final currency = ref.watch(currencyProvider); final fiatValue = value.value * price.price; - - final formatter = NumberFormat.currency( + return NumberFormat.currency( symbol: currency.symbol, name: currency.name, - ); - final decimalFormatter = DecimalFormatter(formatter); - return decimalFormatter.format(fiatValue); + ).format(DecimalIntl(fiatValue)); }); final fiatForAmountProvider = @@ -197,18 +190,18 @@ final fiatForAmountProvider = if (fiatValue == Decimal.zero) { return '0'; } - final formatter = NumberFormat.currency( + final formater = NumberFormat.currency( symbol: currency.symbol, name: currency.name, ); - final decimalFormatter = DecimalFormatter(formatter); - return decimalFormatter - .format(fiatValue) - .replaceAll(formatter.currencySymbol, ''); + return formater + .format(DecimalIntl(fiatValue)) + .replaceAll(formater.currencySymbol, ''); }); final spectreFormatterProvider = Provider((ref) { - final format = NumberFormat.currency(name: '', symbol: 'SPR'); + final symbol = ref.watch(sprSymbolProvider); + final format = NumberFormat.currency(name: '', symbol: symbol); final formatter = CurrencyFormatter( groupSeparator: format.symbols.GROUP_SEP, decimalSeparator: format.symbols.DECIMAL_SEP, @@ -228,10 +221,16 @@ final fiatFormatterProvider = Provider.autoDispose((ref) { name: currency.name, symbol: currency.symbol, ); + + var maxDecimalDigits = format.decimalDigits ?? 2; + if (currency.currency == AvailableCurrencies.BTC) { + maxDecimalDigits = 8; + } + final formatter = CurrencyFormatter( groupSeparator: format.symbols.GROUP_SEP, decimalSeparator: format.symbols.DECIMAL_SEP, - maxDecimalDigits: format.decimalDigits ?? 2, + maxDecimalDigits: maxDecimalDigits, maxAmount: maxAmount, ); diff --git a/lib/wallet_home/wallet_home.dart b/lib/wallet_home/wallet_home.dart index e5cc35a..39f44fb 100644 --- a/lib/wallet_home/wallet_home.dart +++ b/lib/wallet_home/wallet_home.dart @@ -22,6 +22,7 @@ final _walletWatcherProvider = Provider.autoDispose((ref) { ref.watch(txNotifierProvider); ref.watch(utxoNotifierProvider); ref.watch(utxoListProvider); + ref.watch(pendingTxsProvider); ref.watch(addressMonitorProvider); }); diff --git a/lib/widgets/action_button.dart b/lib/widgets/action_button.dart index b77aed0..ef6f652 100644 --- a/lib/widgets/action_button.dart +++ b/lib/widgets/action_button.dart @@ -1,10 +1,11 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import '../core/core_providers.dart'; +import '../app_providers.dart'; import '../l10n/l10n.dart'; import '../receive/receive_sheet.dart'; import '../send_sheet/send_sheet.dart'; +import '../util/ui_util.dart'; import 'sheet_util.dart'; class ActionButton extends ConsumerWidget { @@ -85,16 +86,25 @@ class SendActionButton extends ConsumerWidget { final theme = ref.watch(themeProvider); final l10n = l10nOf(context); - return ActionButton( - title: l10n.send, - onPressed: () { + Future sendAction() async { + if (onPressed != null) { onPressed?.call(); + return; + } + + final (:cont, :rbf) = await UIUtil.checkForPendingTx(context, ref: ref); + if (cont) { Sheets.showAppHeightNineSheet( context: context, - widget: const SendSheet(), + widget: SendSheet(rbf: rbf), theme: theme, ); - }, + } + } + + return ActionButton( + title: l10n.send, + onPressed: sendAction, ); } } diff --git a/lib/widgets/amount_card.dart b/lib/widgets/amount_card.dart index 769325b..b00b0f4 100644 --- a/lib/widgets/amount_card.dart +++ b/lib/widgets/amount_card.dart @@ -5,19 +5,28 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../core/core_providers.dart'; import '../spectre/types.dart'; import '../util/numberutil.dart'; +import 'app_text_field.dart'; import 'fiat_value_container.dart'; import 'spr_icon_widget.dart'; class AmountCard extends HookConsumerWidget { final Amount amount; - const AmountCard({Key? key, required this.amount}) : super(key: key); + final TextFieldButton? rightButton; + + const AmountCard({ + super.key, + required this.amount, + this.rightButton, + }); @override Widget build(BuildContext context, WidgetRef ref) { final theme = ref.watch(themeProvider); final styles = ref.watch(stylesProvider); + final symbol = ref.watch(symbolProvider(amount)); + final formatedAmount = useMemoized(() { return NumberUtil.formatedAmount(amount); }, [amount]); @@ -27,7 +36,7 @@ class AmountCard extends HookConsumerWidget { amount: amount, precision: amount.decimals, ) + - ' ${amount.symbolLabel}'; + ' $symbol'; }, [amount]); return Container( @@ -74,7 +83,7 @@ class AmountCard extends HookConsumerWidget { style: styles.textStyleParagraphPrimary, ), TextSpan( - text: ' ${amount.symbolLabel}', + text: ' $symbol', style: styles.textStyleParagraphPrimaryW100, ), ], @@ -84,7 +93,7 @@ class AmountCard extends HookConsumerWidget { ), ), ), - const SizedBox(width: 48), + rightButton ?? const SizedBox(width: 48), ], ), ), diff --git a/lib/widgets/amount_label.dart b/lib/widgets/amount_label.dart index d125ec6..568506c 100644 --- a/lib/widgets/amount_label.dart +++ b/lib/widgets/amount_label.dart @@ -17,6 +17,8 @@ class AmountLabel extends HookConsumerWidget { final theme = ref.watch(themeProvider); final styles = ref.watch(stylesProvider); + final symbol = ref.watch(symbolProvider(amount)); + final formatedAmount = useMemoized(() { return NumberUtil.formatedAmount(amount); }, [amount]); @@ -26,7 +28,7 @@ class AmountLabel extends HookConsumerWidget { amount: amount, precision: amount.decimals, ) + - ' ${amount.symbolLabel}'; + ' $symbol'; }, [amount]); return FiatValueContainer( @@ -60,7 +62,7 @@ class AmountLabel extends HookConsumerWidget { style: styles.textStyleApproxAmountSuccess, ), TextSpan( - text: ' ${amount.symbolLabel}', + text: ' $symbol', style: styles.textStyleTokenSymbolSuccess, ), ], diff --git a/lib/widgets/fiat_value_widget.dart b/lib/widgets/fiat_value_widget.dart index dcfa18f..bfeedf5 100644 --- a/lib/widgets/fiat_value_widget.dart +++ b/lib/widgets/fiat_value_widget.dart @@ -25,6 +25,7 @@ class FiatValueWidget extends ConsumerWidget { final fiatValue = ref.watch(formatedFiatForAmountProvider(amount)); final amountValue = NumberUtil.formatedAmount(amount); + final symbol = ref.watch(sprSymbolProvider); return Visibility( visible: amount.value != Decimal.zero, @@ -39,7 +40,7 @@ class FiatValueWidget extends ConsumerWidget { ), child: Container( child: Text( - showAmount ? '≈ $amountValue SPR' : '≈ $fiatValue', + showAmount ? '≈ $amountValue $symbol' : '≈ $fiatValue', style: styles.textStyleTransactionAmount, ), ), diff --git a/lib/widgets/pending_tx_dialog.dart b/lib/widgets/pending_tx_dialog.dart new file mode 100644 index 0000000..437e92c --- /dev/null +++ b/lib/widgets/pending_tx_dialog.dart @@ -0,0 +1,81 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import '../app_providers.dart'; +import '../app_router.dart'; +import '../l10n/l10n.dart'; +import '../transactions/transaction_types.dart'; +import '../util/ui_util.dart'; +import 'app_simpledialog.dart'; + +class PendingTxDialog extends HookConsumerWidget { + final Tx pendingTx; + final BuildContext safeContext; + final WidgetRef safeRef; + + const PendingTxDialog({ + super.key, + required this.pendingTx, + required this.safeContext, + required this.safeRef, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final styles = ref.watch(stylesProvider); + final l10n = l10nOf(context); + + Future updateFee() async { + final address = pendingTx.apiTx.outputs.first.scriptPublicKeyAddress; + UIUtil.showUpdateFeeFlow( + safeContext, + ref: safeRef, + tx: pendingTx, + address: address, + ); + } + + return AppAlertDialog( + title: Text( + l10n.txPendingTitle, + style: styles.textStyleButtonPrimaryOutline, + ), + contentPadding: const EdgeInsetsDirectional.fromSTEB(12, 20, 24, 12), + content: SingleChildScrollView( + child: Column( + children: [ + Padding( + padding: const EdgeInsetsDirectional.only(start: 12), + child: Text( + l10n.txPendingContent, + style: styles.textStyleParagraph, + ), + ), + const SizedBox(height: 12), + ], + ), + ), + actions: [ + TextButton( + style: styles.dialogButtonStyle, + child: Text( + l10n.txPendingActionUpdateFee, + style: styles.textStyleDialogOptions, + ), + onPressed: () async { + appRouter.pop(context); + updateFee(); + }, + ), + TextButton( + style: styles.dialogButtonStyle, + child: Text( + l10n.txPendingActionRbf, + style: styles.textStyleDialogOptions, + ), + onPressed: () => appRouter.pop(context, withResult: true), + ), + ], + ); + } +} diff --git a/lib/widgets/sheet_widget.dart b/lib/widgets/sheet_widget.dart index e497325..4d24780 100644 --- a/lib/widgets/sheet_widget.dart +++ b/lib/widgets/sheet_widget.dart @@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../app_providers.dart'; import 'sheet_handle.dart'; +import 'tap_outside_unfocus.dart'; class SheetWidget extends ConsumerWidget { final Widget? leftWidget; @@ -25,51 +26,54 @@ class SheetWidget extends ConsumerWidget { final styles = ref.watch(stylesProvider); final leftRight = (leftWidget ?? rightWidget) != null; - return SafeArea( - minimum: EdgeInsets.only( - bottom: MediaQuery.of(context).size.height * 0.035, - ), - child: Column( - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (leftRight) - Padding( - padding: const EdgeInsetsDirectional.only(top: 10, start: 10), - child: leftWidget ?? const SizedBox(height: 50, width: 50), - ), - Expanded( - child: Column(children: [ - const SheetHandle(), - Container( - margin: const EdgeInsets.symmetric( - horizontal: 4, - vertical: 8, - ), - child: FittedBox( - fit: BoxFit.scaleDown, - child: Text( - title.toUpperCase(), - style: styles.textStyleHeader(context), - textAlign: TextAlign.center, + return TapOutsideUnfocus( + child: SafeArea( + minimum: EdgeInsets.only( + bottom: MediaQuery.of(context).size.height * 0.035, + ), + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (leftRight) + Padding( + padding: + const EdgeInsetsDirectional.only(top: 10, start: 10), + child: leftWidget ?? const SizedBox(height: 50, width: 50), + ), + Expanded( + child: Column(children: [ + const SheetHandle(), + Container( + margin: const EdgeInsets.symmetric( + horizontal: 4, + vertical: 8, + ), + child: FittedBox( + fit: BoxFit.scaleDown, + child: Text( + title.toUpperCase(), + style: styles.textStyleHeader(context), + textAlign: TextAlign.center, + ), ), ), - ), - ]), - ), - if (leftRight) - Padding( - padding: const EdgeInsetsDirectional.only(top: 10, end: 10), - child: rightWidget ?? const SizedBox(height: 50, width: 50), + ]), ), - ], - ), - Expanded(child: mainWidget), - const SizedBox(height: 16), - bottomWidget, - ], + if (leftRight) + Padding( + padding: const EdgeInsetsDirectional.only(top: 10, end: 10), + child: rightWidget ?? const SizedBox(height: 50, width: 50), + ), + ], + ), + Expanded(child: mainWidget), + const SizedBox(height: 16), + bottomWidget, + ], + ), ), ); } diff --git a/lib/widgets/tap_outside_unfocus.dart b/lib/widgets/tap_outside_unfocus.dart index 6e67c35..3b644d3 100644 --- a/lib/widgets/tap_outside_unfocus.dart +++ b/lib/widgets/tap_outside_unfocus.dart @@ -4,9 +4,9 @@ class TapOutsideUnfocus extends StatelessWidget { final Widget child; const TapOutsideUnfocus({ - Key? key, + super.key, required this.child, - }) : super(key: key); + }); @override Widget build(BuildContext context) { diff --git a/missing_translations.txt b/missing_translations.txt index ca6945a..30ca982 100644 --- a/missing_translations.txt +++ b/missing_translations.txt @@ -17,7 +17,27 @@ "bip39PassphraseEnter", "bip39PassphraseConfirm", "bip39PassphraseMismatch", - "bip39PassphraseNote" + "bip39PassphraseNote", + "feePriorityHint", + "feeBaseUppercase", + "feePriorityUppsercase", + "feeUpdateAddressError", + "feeUpdateRebuildError", + "feeUpdateRebuildError2", + "feeUpdateError", + "feeUpdate", + "feeUpdateTitle", + "feeSheetRecommendedPriority", + "feeSheetPriorityFeeWarning", + "txPending", + "txPendingMessage", + "txPendingTitle", + "txPendingContent", + "txPendingActionUpdateFee", + "txPendingActionRbf", + "txInMempool", + "utxoSelectionTitle", + "utxoSelectionHint" ], "ar": [ @@ -38,7 +58,27 @@ "bip39PassphraseEnter", "bip39PassphraseConfirm", "bip39PassphraseMismatch", - "bip39PassphraseNote" + "bip39PassphraseNote", + "feePriorityHint", + "feeBaseUppercase", + "feePriorityUppsercase", + "feeUpdateAddressError", + "feeUpdateRebuildError", + "feeUpdateRebuildError2", + "feeUpdateError", + "feeUpdate", + "feeUpdateTitle", + "feeSheetRecommendedPriority", + "feeSheetPriorityFeeWarning", + "txPending", + "txPendingMessage", + "txPendingTitle", + "txPendingContent", + "txPendingActionUpdateFee", + "txPendingActionRbf", + "txInMempool", + "utxoSelectionTitle", + "utxoSelectionHint" ], "bg": [ @@ -59,7 +99,27 @@ "bip39PassphraseEnter", "bip39PassphraseConfirm", "bip39PassphraseMismatch", - "bip39PassphraseNote" + "bip39PassphraseNote", + "feePriorityHint", + "feeBaseUppercase", + "feePriorityUppsercase", + "feeUpdateAddressError", + "feeUpdateRebuildError", + "feeUpdateRebuildError2", + "feeUpdateError", + "feeUpdate", + "feeUpdateTitle", + "feeSheetRecommendedPriority", + "feeSheetPriorityFeeWarning", + "txPending", + "txPendingMessage", + "txPendingTitle", + "txPendingContent", + "txPendingActionUpdateFee", + "txPendingActionRbf", + "txInMempool", + "utxoSelectionTitle", + "utxoSelectionHint" ], "bn": [ @@ -80,7 +140,27 @@ "bip39PassphraseEnter", "bip39PassphraseConfirm", "bip39PassphraseMismatch", - "bip39PassphraseNote" + "bip39PassphraseNote", + "feePriorityHint", + "feeBaseUppercase", + "feePriorityUppsercase", + "feeUpdateAddressError", + "feeUpdateRebuildError", + "feeUpdateRebuildError2", + "feeUpdateError", + "feeUpdate", + "feeUpdateTitle", + "feeSheetRecommendedPriority", + "feeSheetPriorityFeeWarning", + "txPending", + "txPendingMessage", + "txPendingTitle", + "txPendingContent", + "txPendingActionUpdateFee", + "txPendingActionRbf", + "txInMempool", + "utxoSelectionTitle", + "utxoSelectionHint" ], "ca": [ @@ -101,7 +181,27 @@ "bip39PassphraseEnter", "bip39PassphraseConfirm", "bip39PassphraseMismatch", - "bip39PassphraseNote" + "bip39PassphraseNote", + "feePriorityHint", + "feeBaseUppercase", + "feePriorityUppsercase", + "feeUpdateAddressError", + "feeUpdateRebuildError", + "feeUpdateRebuildError2", + "feeUpdateError", + "feeUpdate", + "feeUpdateTitle", + "feeSheetRecommendedPriority", + "feeSheetPriorityFeeWarning", + "txPending", + "txPendingMessage", + "txPendingTitle", + "txPendingContent", + "txPendingActionUpdateFee", + "txPendingActionRbf", + "txInMempool", + "utxoSelectionTitle", + "utxoSelectionHint" ], "cs": [ @@ -122,7 +222,27 @@ "bip39PassphraseEnter", "bip39PassphraseConfirm", "bip39PassphraseMismatch", - "bip39PassphraseNote" + "bip39PassphraseNote", + "feePriorityHint", + "feeBaseUppercase", + "feePriorityUppsercase", + "feeUpdateAddressError", + "feeUpdateRebuildError", + "feeUpdateRebuildError2", + "feeUpdateError", + "feeUpdate", + "feeUpdateTitle", + "feeSheetRecommendedPriority", + "feeSheetPriorityFeeWarning", + "txPending", + "txPendingMessage", + "txPendingTitle", + "txPendingContent", + "txPendingActionUpdateFee", + "txPendingActionRbf", + "txInMempool", + "utxoSelectionTitle", + "utxoSelectionHint" ], "da": [ @@ -143,7 +263,27 @@ "bip39PassphraseEnter", "bip39PassphraseConfirm", "bip39PassphraseMismatch", - "bip39PassphraseNote" + "bip39PassphraseNote", + "feePriorityHint", + "feeBaseUppercase", + "feePriorityUppsercase", + "feeUpdateAddressError", + "feeUpdateRebuildError", + "feeUpdateRebuildError2", + "feeUpdateError", + "feeUpdate", + "feeUpdateTitle", + "feeSheetRecommendedPriority", + "feeSheetPriorityFeeWarning", + "txPending", + "txPendingMessage", + "txPendingTitle", + "txPendingContent", + "txPendingActionUpdateFee", + "txPendingActionRbf", + "txInMempool", + "utxoSelectionTitle", + "utxoSelectionHint" ], "de": [ @@ -164,7 +304,27 @@ "bip39PassphraseEnter", "bip39PassphraseConfirm", "bip39PassphraseMismatch", - "bip39PassphraseNote" + "bip39PassphraseNote", + "feePriorityHint", + "feeBaseUppercase", + "feePriorityUppsercase", + "feeUpdateAddressError", + "feeUpdateRebuildError", + "feeUpdateRebuildError2", + "feeUpdateError", + "feeUpdate", + "feeUpdateTitle", + "feeSheetRecommendedPriority", + "feeSheetPriorityFeeWarning", + "txPending", + "txPendingMessage", + "txPendingTitle", + "txPendingContent", + "txPendingActionUpdateFee", + "txPendingActionRbf", + "txInMempool", + "utxoSelectionTitle", + "utxoSelectionHint" ], "el": [ @@ -185,7 +345,27 @@ "bip39PassphraseEnter", "bip39PassphraseConfirm", "bip39PassphraseMismatch", - "bip39PassphraseNote" + "bip39PassphraseNote", + "feePriorityHint", + "feeBaseUppercase", + "feePriorityUppsercase", + "feeUpdateAddressError", + "feeUpdateRebuildError", + "feeUpdateRebuildError2", + "feeUpdateError", + "feeUpdate", + "feeUpdateTitle", + "feeSheetRecommendedPriority", + "feeSheetPriorityFeeWarning", + "txPending", + "txPendingMessage", + "txPendingTitle", + "txPendingContent", + "txPendingActionUpdateFee", + "txPendingActionRbf", + "txInMempool", + "utxoSelectionTitle", + "utxoSelectionHint" ], "es": [ @@ -206,7 +386,27 @@ "bip39PassphraseEnter", "bip39PassphraseConfirm", "bip39PassphraseMismatch", - "bip39PassphraseNote" + "bip39PassphraseNote", + "feePriorityHint", + "feeBaseUppercase", + "feePriorityUppsercase", + "feeUpdateAddressError", + "feeUpdateRebuildError", + "feeUpdateRebuildError2", + "feeUpdateError", + "feeUpdate", + "feeUpdateTitle", + "feeSheetRecommendedPriority", + "feeSheetPriorityFeeWarning", + "txPending", + "txPendingMessage", + "txPendingTitle", + "txPendingContent", + "txPendingActionUpdateFee", + "txPendingActionRbf", + "txInMempool", + "utxoSelectionTitle", + "utxoSelectionHint" ], "eu": [ @@ -227,7 +427,27 @@ "bip39PassphraseEnter", "bip39PassphraseConfirm", "bip39PassphraseMismatch", - "bip39PassphraseNote" + "bip39PassphraseNote", + "feePriorityHint", + "feeBaseUppercase", + "feePriorityUppsercase", + "feeUpdateAddressError", + "feeUpdateRebuildError", + "feeUpdateRebuildError2", + "feeUpdateError", + "feeUpdate", + "feeUpdateTitle", + "feeSheetRecommendedPriority", + "feeSheetPriorityFeeWarning", + "txPending", + "txPendingMessage", + "txPendingTitle", + "txPendingContent", + "txPendingActionUpdateFee", + "txPendingActionRbf", + "txInMempool", + "utxoSelectionTitle", + "utxoSelectionHint" ], "fa": [ @@ -248,7 +468,27 @@ "bip39PassphraseEnter", "bip39PassphraseConfirm", "bip39PassphraseMismatch", - "bip39PassphraseNote" + "bip39PassphraseNote", + "feePriorityHint", + "feeBaseUppercase", + "feePriorityUppsercase", + "feeUpdateAddressError", + "feeUpdateRebuildError", + "feeUpdateRebuildError2", + "feeUpdateError", + "feeUpdate", + "feeUpdateTitle", + "feeSheetRecommendedPriority", + "feeSheetPriorityFeeWarning", + "txPending", + "txPendingMessage", + "txPendingTitle", + "txPendingContent", + "txPendingActionUpdateFee", + "txPendingActionRbf", + "txInMempool", + "utxoSelectionTitle", + "utxoSelectionHint" ], "fi": [ @@ -269,7 +509,27 @@ "bip39PassphraseEnter", "bip39PassphraseConfirm", "bip39PassphraseMismatch", - "bip39PassphraseNote" + "bip39PassphraseNote", + "feePriorityHint", + "feeBaseUppercase", + "feePriorityUppsercase", + "feeUpdateAddressError", + "feeUpdateRebuildError", + "feeUpdateRebuildError2", + "feeUpdateError", + "feeUpdate", + "feeUpdateTitle", + "feeSheetRecommendedPriority", + "feeSheetPriorityFeeWarning", + "txPending", + "txPendingMessage", + "txPendingTitle", + "txPendingContent", + "txPendingActionUpdateFee", + "txPendingActionRbf", + "txInMempool", + "utxoSelectionTitle", + "utxoSelectionHint" ], "fr": [ @@ -290,7 +550,27 @@ "bip39PassphraseEnter", "bip39PassphraseConfirm", "bip39PassphraseMismatch", - "bip39PassphraseNote" + "bip39PassphraseNote", + "feePriorityHint", + "feeBaseUppercase", + "feePriorityUppsercase", + "feeUpdateAddressError", + "feeUpdateRebuildError", + "feeUpdateRebuildError2", + "feeUpdateError", + "feeUpdate", + "feeUpdateTitle", + "feeSheetRecommendedPriority", + "feeSheetPriorityFeeWarning", + "txPending", + "txPendingMessage", + "txPendingTitle", + "txPendingContent", + "txPendingActionUpdateFee", + "txPendingActionRbf", + "txInMempool", + "utxoSelectionTitle", + "utxoSelectionHint" ], "he": [ @@ -311,7 +591,27 @@ "bip39PassphraseEnter", "bip39PassphraseConfirm", "bip39PassphraseMismatch", - "bip39PassphraseNote" + "bip39PassphraseNote", + "feePriorityHint", + "feeBaseUppercase", + "feePriorityUppsercase", + "feeUpdateAddressError", + "feeUpdateRebuildError", + "feeUpdateRebuildError2", + "feeUpdateError", + "feeUpdate", + "feeUpdateTitle", + "feeSheetRecommendedPriority", + "feeSheetPriorityFeeWarning", + "txPending", + "txPendingMessage", + "txPendingTitle", + "txPendingContent", + "txPendingActionUpdateFee", + "txPendingActionRbf", + "txInMempool", + "utxoSelectionTitle", + "utxoSelectionHint" ], "hi": [ @@ -332,7 +632,27 @@ "bip39PassphraseEnter", "bip39PassphraseConfirm", "bip39PassphraseMismatch", - "bip39PassphraseNote" + "bip39PassphraseNote", + "feePriorityHint", + "feeBaseUppercase", + "feePriorityUppsercase", + "feeUpdateAddressError", + "feeUpdateRebuildError", + "feeUpdateRebuildError2", + "feeUpdateError", + "feeUpdate", + "feeUpdateTitle", + "feeSheetRecommendedPriority", + "feeSheetPriorityFeeWarning", + "txPending", + "txPendingMessage", + "txPendingTitle", + "txPendingContent", + "txPendingActionUpdateFee", + "txPendingActionRbf", + "txInMempool", + "utxoSelectionTitle", + "utxoSelectionHint" ], "hu": [ @@ -353,7 +673,27 @@ "bip39PassphraseEnter", "bip39PassphraseConfirm", "bip39PassphraseMismatch", - "bip39PassphraseNote" + "bip39PassphraseNote", + "feePriorityHint", + "feeBaseUppercase", + "feePriorityUppsercase", + "feeUpdateAddressError", + "feeUpdateRebuildError", + "feeUpdateRebuildError2", + "feeUpdateError", + "feeUpdate", + "feeUpdateTitle", + "feeSheetRecommendedPriority", + "feeSheetPriorityFeeWarning", + "txPending", + "txPendingMessage", + "txPendingTitle", + "txPendingContent", + "txPendingActionUpdateFee", + "txPendingActionRbf", + "txInMempool", + "utxoSelectionTitle", + "utxoSelectionHint" ], "id": [ @@ -374,7 +714,27 @@ "bip39PassphraseEnter", "bip39PassphraseConfirm", "bip39PassphraseMismatch", - "bip39PassphraseNote" + "bip39PassphraseNote", + "feePriorityHint", + "feeBaseUppercase", + "feePriorityUppsercase", + "feeUpdateAddressError", + "feeUpdateRebuildError", + "feeUpdateRebuildError2", + "feeUpdateError", + "feeUpdate", + "feeUpdateTitle", + "feeSheetRecommendedPriority", + "feeSheetPriorityFeeWarning", + "txPending", + "txPendingMessage", + "txPendingTitle", + "txPendingContent", + "txPendingActionUpdateFee", + "txPendingActionRbf", + "txInMempool", + "utxoSelectionTitle", + "utxoSelectionHint" ], "it": [ @@ -395,7 +755,27 @@ "bip39PassphraseEnter", "bip39PassphraseConfirm", "bip39PassphraseMismatch", - "bip39PassphraseNote" + "bip39PassphraseNote", + "feePriorityHint", + "feeBaseUppercase", + "feePriorityUppsercase", + "feeUpdateAddressError", + "feeUpdateRebuildError", + "feeUpdateRebuildError2", + "feeUpdateError", + "feeUpdate", + "feeUpdateTitle", + "feeSheetRecommendedPriority", + "feeSheetPriorityFeeWarning", + "txPending", + "txPendingMessage", + "txPendingTitle", + "txPendingContent", + "txPendingActionUpdateFee", + "txPendingActionRbf", + "txInMempool", + "utxoSelectionTitle", + "utxoSelectionHint" ], "ja": [ @@ -416,7 +796,27 @@ "bip39PassphraseEnter", "bip39PassphraseConfirm", "bip39PassphraseMismatch", - "bip39PassphraseNote" + "bip39PassphraseNote", + "feePriorityHint", + "feeBaseUppercase", + "feePriorityUppsercase", + "feeUpdateAddressError", + "feeUpdateRebuildError", + "feeUpdateRebuildError2", + "feeUpdateError", + "feeUpdate", + "feeUpdateTitle", + "feeSheetRecommendedPriority", + "feeSheetPriorityFeeWarning", + "txPending", + "txPendingMessage", + "txPendingTitle", + "txPendingContent", + "txPendingActionUpdateFee", + "txPendingActionRbf", + "txInMempool", + "utxoSelectionTitle", + "utxoSelectionHint" ], "ka": [ @@ -437,7 +837,27 @@ "bip39PassphraseEnter", "bip39PassphraseConfirm", "bip39PassphraseMismatch", - "bip39PassphraseNote" + "bip39PassphraseNote", + "feePriorityHint", + "feeBaseUppercase", + "feePriorityUppsercase", + "feeUpdateAddressError", + "feeUpdateRebuildError", + "feeUpdateRebuildError2", + "feeUpdateError", + "feeUpdate", + "feeUpdateTitle", + "feeSheetRecommendedPriority", + "feeSheetPriorityFeeWarning", + "txPending", + "txPendingMessage", + "txPendingTitle", + "txPendingContent", + "txPendingActionUpdateFee", + "txPendingActionRbf", + "txInMempool", + "utxoSelectionTitle", + "utxoSelectionHint" ], "ko": [ @@ -458,7 +878,27 @@ "bip39PassphraseEnter", "bip39PassphraseConfirm", "bip39PassphraseMismatch", - "bip39PassphraseNote" + "bip39PassphraseNote", + "feePriorityHint", + "feeBaseUppercase", + "feePriorityUppsercase", + "feeUpdateAddressError", + "feeUpdateRebuildError", + "feeUpdateRebuildError2", + "feeUpdateError", + "feeUpdate", + "feeUpdateTitle", + "feeSheetRecommendedPriority", + "feeSheetPriorityFeeWarning", + "txPending", + "txPendingMessage", + "txPendingTitle", + "txPendingContent", + "txPendingActionUpdateFee", + "txPendingActionRbf", + "txInMempool", + "utxoSelectionTitle", + "utxoSelectionHint" ], "lv": [ @@ -479,7 +919,27 @@ "bip39PassphraseEnter", "bip39PassphraseConfirm", "bip39PassphraseMismatch", - "bip39PassphraseNote" + "bip39PassphraseNote", + "feePriorityHint", + "feeBaseUppercase", + "feePriorityUppsercase", + "feeUpdateAddressError", + "feeUpdateRebuildError", + "feeUpdateRebuildError2", + "feeUpdateError", + "feeUpdate", + "feeUpdateTitle", + "feeSheetRecommendedPriority", + "feeSheetPriorityFeeWarning", + "txPending", + "txPendingMessage", + "txPendingTitle", + "txPendingContent", + "txPendingActionUpdateFee", + "txPendingActionRbf", + "txInMempool", + "utxoSelectionTitle", + "utxoSelectionHint" ], "ms": [ @@ -500,7 +960,27 @@ "bip39PassphraseEnter", "bip39PassphraseConfirm", "bip39PassphraseMismatch", - "bip39PassphraseNote" + "bip39PassphraseNote", + "feePriorityHint", + "feeBaseUppercase", + "feePriorityUppsercase", + "feeUpdateAddressError", + "feeUpdateRebuildError", + "feeUpdateRebuildError2", + "feeUpdateError", + "feeUpdate", + "feeUpdateTitle", + "feeSheetRecommendedPriority", + "feeSheetPriorityFeeWarning", + "txPending", + "txPendingMessage", + "txPendingTitle", + "txPendingContent", + "txPendingActionUpdateFee", + "txPendingActionRbf", + "txInMempool", + "utxoSelectionTitle", + "utxoSelectionHint" ], "nl": [ @@ -521,7 +1001,27 @@ "bip39PassphraseEnter", "bip39PassphraseConfirm", "bip39PassphraseMismatch", - "bip39PassphraseNote" + "bip39PassphraseNote", + "feePriorityHint", + "feeBaseUppercase", + "feePriorityUppsercase", + "feeUpdateAddressError", + "feeUpdateRebuildError", + "feeUpdateRebuildError2", + "feeUpdateError", + "feeUpdate", + "feeUpdateTitle", + "feeSheetRecommendedPriority", + "feeSheetPriorityFeeWarning", + "txPending", + "txPendingMessage", + "txPendingTitle", + "txPendingContent", + "txPendingActionUpdateFee", + "txPendingActionRbf", + "txInMempool", + "utxoSelectionTitle", + "utxoSelectionHint" ], "no": [ @@ -542,7 +1042,27 @@ "bip39PassphraseEnter", "bip39PassphraseConfirm", "bip39PassphraseMismatch", - "bip39PassphraseNote" + "bip39PassphraseNote", + "feePriorityHint", + "feeBaseUppercase", + "feePriorityUppsercase", + "feeUpdateAddressError", + "feeUpdateRebuildError", + "feeUpdateRebuildError2", + "feeUpdateError", + "feeUpdate", + "feeUpdateTitle", + "feeSheetRecommendedPriority", + "feeSheetPriorityFeeWarning", + "txPending", + "txPendingMessage", + "txPendingTitle", + "txPendingContent", + "txPendingActionUpdateFee", + "txPendingActionRbf", + "txInMempool", + "utxoSelectionTitle", + "utxoSelectionHint" ], "pl": [ @@ -563,7 +1083,27 @@ "bip39PassphraseEnter", "bip39PassphraseConfirm", "bip39PassphraseMismatch", - "bip39PassphraseNote" + "bip39PassphraseNote", + "feePriorityHint", + "feeBaseUppercase", + "feePriorityUppsercase", + "feeUpdateAddressError", + "feeUpdateRebuildError", + "feeUpdateRebuildError2", + "feeUpdateError", + "feeUpdate", + "feeUpdateTitle", + "feeSheetRecommendedPriority", + "feeSheetPriorityFeeWarning", + "txPending", + "txPendingMessage", + "txPendingTitle", + "txPendingContent", + "txPendingActionUpdateFee", + "txPendingActionRbf", + "txInMempool", + "utxoSelectionTitle", + "utxoSelectionHint" ], "pt": [ @@ -584,7 +1124,27 @@ "bip39PassphraseEnter", "bip39PassphraseConfirm", "bip39PassphraseMismatch", - "bip39PassphraseNote" + "bip39PassphraseNote", + "feePriorityHint", + "feeBaseUppercase", + "feePriorityUppsercase", + "feeUpdateAddressError", + "feeUpdateRebuildError", + "feeUpdateRebuildError2", + "feeUpdateError", + "feeUpdate", + "feeUpdateTitle", + "feeSheetRecommendedPriority", + "feeSheetPriorityFeeWarning", + "txPending", + "txPendingMessage", + "txPendingTitle", + "txPendingContent", + "txPendingActionUpdateFee", + "txPendingActionRbf", + "txInMempool", + "utxoSelectionTitle", + "utxoSelectionHint" ], "ro": [ @@ -605,7 +1165,27 @@ "bip39PassphraseEnter", "bip39PassphraseConfirm", "bip39PassphraseMismatch", - "bip39PassphraseNote" + "bip39PassphraseNote", + "feePriorityHint", + "feeBaseUppercase", + "feePriorityUppsercase", + "feeUpdateAddressError", + "feeUpdateRebuildError", + "feeUpdateRebuildError2", + "feeUpdateError", + "feeUpdate", + "feeUpdateTitle", + "feeSheetRecommendedPriority", + "feeSheetPriorityFeeWarning", + "txPending", + "txPendingMessage", + "txPendingTitle", + "txPendingContent", + "txPendingActionUpdateFee", + "txPendingActionRbf", + "txInMempool", + "utxoSelectionTitle", + "utxoSelectionHint" ], "ru": [ @@ -626,7 +1206,27 @@ "bip39PassphraseEnter", "bip39PassphraseConfirm", "bip39PassphraseMismatch", - "bip39PassphraseNote" + "bip39PassphraseNote", + "feePriorityHint", + "feeBaseUppercase", + "feePriorityUppsercase", + "feeUpdateAddressError", + "feeUpdateRebuildError", + "feeUpdateRebuildError2", + "feeUpdateError", + "feeUpdate", + "feeUpdateTitle", + "feeSheetRecommendedPriority", + "feeSheetPriorityFeeWarning", + "txPending", + "txPendingMessage", + "txPendingTitle", + "txPendingContent", + "txPendingActionUpdateFee", + "txPendingActionRbf", + "txInMempool", + "utxoSelectionTitle", + "utxoSelectionHint" ], "sk": [ @@ -647,7 +1247,27 @@ "bip39PassphraseEnter", "bip39PassphraseConfirm", "bip39PassphraseMismatch", - "bip39PassphraseNote" + "bip39PassphraseNote", + "feePriorityHint", + "feeBaseUppercase", + "feePriorityUppsercase", + "feeUpdateAddressError", + "feeUpdateRebuildError", + "feeUpdateRebuildError2", + "feeUpdateError", + "feeUpdate", + "feeUpdateTitle", + "feeSheetRecommendedPriority", + "feeSheetPriorityFeeWarning", + "txPending", + "txPendingMessage", + "txPendingTitle", + "txPendingContent", + "txPendingActionUpdateFee", + "txPendingActionRbf", + "txInMempool", + "utxoSelectionTitle", + "utxoSelectionHint" ], "sl": [ @@ -668,7 +1288,27 @@ "bip39PassphraseEnter", "bip39PassphraseConfirm", "bip39PassphraseMismatch", - "bip39PassphraseNote" + "bip39PassphraseNote", + "feePriorityHint", + "feeBaseUppercase", + "feePriorityUppsercase", + "feeUpdateAddressError", + "feeUpdateRebuildError", + "feeUpdateRebuildError2", + "feeUpdateError", + "feeUpdate", + "feeUpdateTitle", + "feeSheetRecommendedPriority", + "feeSheetPriorityFeeWarning", + "txPending", + "txPendingMessage", + "txPendingTitle", + "txPendingContent", + "txPendingActionUpdateFee", + "txPendingActionRbf", + "txInMempool", + "utxoSelectionTitle", + "utxoSelectionHint" ], "sq": [ @@ -689,7 +1329,27 @@ "bip39PassphraseEnter", "bip39PassphraseConfirm", "bip39PassphraseMismatch", - "bip39PassphraseNote" + "bip39PassphraseNote", + "feePriorityHint", + "feeBaseUppercase", + "feePriorityUppsercase", + "feeUpdateAddressError", + "feeUpdateRebuildError", + "feeUpdateRebuildError2", + "feeUpdateError", + "feeUpdate", + "feeUpdateTitle", + "feeSheetRecommendedPriority", + "feeSheetPriorityFeeWarning", + "txPending", + "txPendingMessage", + "txPendingTitle", + "txPendingContent", + "txPendingActionUpdateFee", + "txPendingActionRbf", + "txInMempool", + "utxoSelectionTitle", + "utxoSelectionHint" ], "sr": [ @@ -710,7 +1370,27 @@ "bip39PassphraseEnter", "bip39PassphraseConfirm", "bip39PassphraseMismatch", - "bip39PassphraseNote" + "bip39PassphraseNote", + "feePriorityHint", + "feeBaseUppercase", + "feePriorityUppsercase", + "feeUpdateAddressError", + "feeUpdateRebuildError", + "feeUpdateRebuildError2", + "feeUpdateError", + "feeUpdate", + "feeUpdateTitle", + "feeSheetRecommendedPriority", + "feeSheetPriorityFeeWarning", + "txPending", + "txPendingMessage", + "txPendingTitle", + "txPendingContent", + "txPendingActionUpdateFee", + "txPendingActionRbf", + "txInMempool", + "utxoSelectionTitle", + "utxoSelectionHint" ], "sv": [ @@ -731,7 +1411,27 @@ "bip39PassphraseEnter", "bip39PassphraseConfirm", "bip39PassphraseMismatch", - "bip39PassphraseNote" + "bip39PassphraseNote", + "feePriorityHint", + "feeBaseUppercase", + "feePriorityUppsercase", + "feeUpdateAddressError", + "feeUpdateRebuildError", + "feeUpdateRebuildError2", + "feeUpdateError", + "feeUpdate", + "feeUpdateTitle", + "feeSheetRecommendedPriority", + "feeSheetPriorityFeeWarning", + "txPending", + "txPendingMessage", + "txPendingTitle", + "txPendingContent", + "txPendingActionUpdateFee", + "txPendingActionRbf", + "txInMempool", + "utxoSelectionTitle", + "utxoSelectionHint" ], "te": [ @@ -752,7 +1452,27 @@ "bip39PassphraseEnter", "bip39PassphraseConfirm", "bip39PassphraseMismatch", - "bip39PassphraseNote" + "bip39PassphraseNote", + "feePriorityHint", + "feeBaseUppercase", + "feePriorityUppsercase", + "feeUpdateAddressError", + "feeUpdateRebuildError", + "feeUpdateRebuildError2", + "feeUpdateError", + "feeUpdate", + "feeUpdateTitle", + "feeSheetRecommendedPriority", + "feeSheetPriorityFeeWarning", + "txPending", + "txPendingMessage", + "txPendingTitle", + "txPendingContent", + "txPendingActionUpdateFee", + "txPendingActionRbf", + "txInMempool", + "utxoSelectionTitle", + "utxoSelectionHint" ], "tl": [ @@ -773,7 +1493,27 @@ "bip39PassphraseEnter", "bip39PassphraseConfirm", "bip39PassphraseMismatch", - "bip39PassphraseNote" + "bip39PassphraseNote", + "feePriorityHint", + "feeBaseUppercase", + "feePriorityUppsercase", + "feeUpdateAddressError", + "feeUpdateRebuildError", + "feeUpdateRebuildError2", + "feeUpdateError", + "feeUpdate", + "feeUpdateTitle", + "feeSheetRecommendedPriority", + "feeSheetPriorityFeeWarning", + "txPending", + "txPendingMessage", + "txPendingTitle", + "txPendingContent", + "txPendingActionUpdateFee", + "txPendingActionRbf", + "txInMempool", + "utxoSelectionTitle", + "utxoSelectionHint" ], "tr": [ @@ -794,7 +1534,27 @@ "bip39PassphraseEnter", "bip39PassphraseConfirm", "bip39PassphraseMismatch", - "bip39PassphraseNote" + "bip39PassphraseNote", + "feePriorityHint", + "feeBaseUppercase", + "feePriorityUppsercase", + "feeUpdateAddressError", + "feeUpdateRebuildError", + "feeUpdateRebuildError2", + "feeUpdateError", + "feeUpdate", + "feeUpdateTitle", + "feeSheetRecommendedPriority", + "feeSheetPriorityFeeWarning", + "txPending", + "txPendingMessage", + "txPendingTitle", + "txPendingContent", + "txPendingActionUpdateFee", + "txPendingActionRbf", + "txInMempool", + "utxoSelectionTitle", + "utxoSelectionHint" ], "uk": [ @@ -815,7 +1575,27 @@ "bip39PassphraseEnter", "bip39PassphraseConfirm", "bip39PassphraseMismatch", - "bip39PassphraseNote" + "bip39PassphraseNote", + "feePriorityHint", + "feeBaseUppercase", + "feePriorityUppsercase", + "feeUpdateAddressError", + "feeUpdateRebuildError", + "feeUpdateRebuildError2", + "feeUpdateError", + "feeUpdate", + "feeUpdateTitle", + "feeSheetRecommendedPriority", + "feeSheetPriorityFeeWarning", + "txPending", + "txPendingMessage", + "txPendingTitle", + "txPendingContent", + "txPendingActionUpdateFee", + "txPendingActionRbf", + "txInMempool", + "utxoSelectionTitle", + "utxoSelectionHint" ], "ur": [ @@ -836,7 +1616,27 @@ "bip39PassphraseEnter", "bip39PassphraseConfirm", "bip39PassphraseMismatch", - "bip39PassphraseNote" + "bip39PassphraseNote", + "feePriorityHint", + "feeBaseUppercase", + "feePriorityUppsercase", + "feeUpdateAddressError", + "feeUpdateRebuildError", + "feeUpdateRebuildError2", + "feeUpdateError", + "feeUpdate", + "feeUpdateTitle", + "feeSheetRecommendedPriority", + "feeSheetPriorityFeeWarning", + "txPending", + "txPendingMessage", + "txPendingTitle", + "txPendingContent", + "txPendingActionUpdateFee", + "txPendingActionRbf", + "txInMempool", + "utxoSelectionTitle", + "utxoSelectionHint" ], "vi": [ @@ -857,7 +1657,27 @@ "bip39PassphraseEnter", "bip39PassphraseConfirm", "bip39PassphraseMismatch", - "bip39PassphraseNote" + "bip39PassphraseNote", + "feePriorityHint", + "feeBaseUppercase", + "feePriorityUppsercase", + "feeUpdateAddressError", + "feeUpdateRebuildError", + "feeUpdateRebuildError2", + "feeUpdateError", + "feeUpdate", + "feeUpdateTitle", + "feeSheetRecommendedPriority", + "feeSheetPriorityFeeWarning", + "txPending", + "txPendingMessage", + "txPendingTitle", + "txPendingContent", + "txPendingActionUpdateFee", + "txPendingActionRbf", + "txInMempool", + "utxoSelectionTitle", + "utxoSelectionHint" ], "zh": [ @@ -878,6 +1698,26 @@ "bip39PassphraseEnter", "bip39PassphraseConfirm", "bip39PassphraseMismatch", - "bip39PassphraseNote" + "bip39PassphraseNote", + "feePriorityHint", + "feeBaseUppercase", + "feePriorityUppsercase", + "feeUpdateAddressError", + "feeUpdateRebuildError", + "feeUpdateRebuildError2", + "feeUpdateError", + "feeUpdate", + "feeUpdateTitle", + "feeSheetRecommendedPriority", + "feeSheetPriorityFeeWarning", + "txPending", + "txPendingMessage", + "txPendingTitle", + "txPendingContent", + "txPendingActionUpdateFee", + "txPendingActionRbf", + "txInMempool", + "utxoSelectionTitle", + "utxoSelectionHint" ] } diff --git a/pubspec.lock b/pubspec.lock index ca82074..a7df81f 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -251,10 +251,10 @@ packages: dependency: "direct main" description: name: decimal - sha256: "4140a688f9e443e2f4de3a1162387bf25e1ac6d51e24c9da263f245210f41440" + sha256: "24a261d5d5c87e86c7651c417a5dbdf8bcd7080dd592533910e8d0505a279f21" url: "https://pub.dev" source: hosted - version: "3.0.2" + version: "2.3.3" device_info_plus: dependency: "direct main" description: @@ -478,6 +478,14 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_vibrate: + dependency: "direct main" + description: + name: flutter_vibrate + sha256: "9cc9b32cf52c90dd34c1cf396ed40010b2c74e69adbb0ff16005afa900971ad8" + url: "https://pub.dev" + source: hosted + version: "1.3.0" flutter_web_plugins: dependency: transitive description: flutter @@ -1328,22 +1336,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.4" - vibration: - dependency: "direct main" - description: - name: vibration - sha256: f0af02af2d63132135ae0332a3e54d5de718e214ee94c4f082176ef6ce624a4b - url: "https://pub.dev" - source: hosted - version: "2.0.1" - vibration_platform_interface: - dependency: transitive - description: - name: vibration_platform_interface - sha256: f66b39aab2447038978c16f3d6f77228e49ef5717556e3da02313e044e4a7600 - url: "https://pub.dev" - source: hosted - version: "0.0.2" vm_service: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index f140fe4..07cdd3e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -2,7 +2,7 @@ name: spectrum description: The Ultimate Self-Custodial Wallet for the Spectre Network. publish_to: "none" -version: 0.3.19+1 +version: 0.3.20+1 environment: sdk: '>=3.3.0 <4.0.0' @@ -20,7 +20,7 @@ dependencies: device_info_plus: 10.1.1 json_annotation: ^4.9.0 intl: 0.19.0 - decimal: ^3.0.2 + decimal: 2.3.3 logger: ^2.4.0 hive_flutter: 1.1.0 path_provider: 2.1.3 @@ -31,7 +31,7 @@ dependencies: file_picker: ^8.0.6 app_links: ^6.1.4 http: 1.2.2 - vibration: ^2.0.0 + flutter_vibrate: 1.3.0 flutter_slidable: ^3.1.1 validators: 3.0.0 auto_size_text: 3.0.0