diff --git a/Dockerfile b/Dockerfile index 72e8cd3e2c..3fa3a25e16 100644 --- a/Dockerfile +++ b/Dockerfile @@ -63,11 +63,14 @@ RUN mkdir -p "$ANDROID_SDK_ROOT/cmdline-tools" \ && sdkmanager \ "platform-tools" \ "build-tools;35.0.0" \ + "build-tools;36.0.0" \ + "build-tools;37.0.0" \ "platforms;android-32" \ "platforms;android-33" \ "platforms;android-34" \ "platforms;android-35" \ "platforms;android-36" \ + "platforms;android-37" \ "ndk;28.0.13004108" \ "ndk;28.2.13676358" \ "cmake;3.22.1" \ @@ -148,11 +151,14 @@ RUN mkdir -p "$ANDROID_SDK_ROOT/cmdline-tools" \ && sdkmanager \ "platform-tools" \ "build-tools;35.0.0" \ + "build-tools;36.0.0" \ + "build-tools;37.0.0" \ "platforms;android-32" \ "platforms;android-33" \ "platforms;android-34" \ "platforms;android-35" \ "platforms;android-36" \ + "platforms;android-37" \ "ndk;28.0.13004108" \ "ndk;28.2.13676358" \ "cmake;3.22.1" \ diff --git a/android/gradle.properties b/android/gradle.properties index 24863d2185..1dadf40b85 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -1,3 +1,6 @@ org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError android.useAndroidX=true -android.enableJetifier=true \ No newline at end of file +# Jetifier removed in AGP 9; all deps are AndroidX already. +# Preserve Flutter's legacy Kotlin and Android DSL compatibility on AGP 9. +android.newDsl=false +android.builtInKotlin=false diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties index e4ef43fb98..a20f2c46d2 100644 --- a/android/gradle/wrapper/gradle-wrapper.properties +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-all.zip diff --git a/android/settings.gradle b/android/settings.gradle index ebf08564f2..67b6e47cd1 100644 --- a/android/settings.gradle +++ b/android/settings.gradle @@ -18,8 +18,8 @@ pluginManagement { plugins { id "dev.flutter.flutter-plugin-loader" version "1.0.0" - id "com.android.application" version '8.11.1' apply false - id "org.jetbrains.kotlin.android" version "2.2.20" apply false + id "com.android.application" version '9.1.1' apply false + id "org.jetbrains.kotlin.android" version "2.3.20" apply false } include ":app" diff --git a/lib/db/isar/main_db.dart b/lib/db/isar/main_db.dart index 8958114736..b304442eb3 100644 --- a/lib/db/isar/main_db.dart +++ b/lib/db/isar/main_db.dart @@ -10,7 +10,6 @@ import 'dart:io'; -import 'package:decimal/decimal.dart'; import 'package:isar_community/isar.dart'; import 'package:tuple/tuple.dart'; @@ -349,6 +348,7 @@ class MainDB { blockTime: utxo.blockTime, blockHeight: utxo.blockHeight, blockHash: utxo.blockHash, + otherData: utxo.otherData, // passing null keeps the stored value isBlocked: applyAutoBlock ? true : null, blockedReason: applyAutoBlock ? utxo.blockedReason : null, @@ -463,52 +463,14 @@ class MainDB { // Future deleteWalletBlockchainData(String walletId) async { await isar.writeTxn(() async { - final transactionCount = await getTransactions(walletId).count(); - final transactionCountV2 = await isar.transactionV2s - .where() - .walletIdEqualTo(walletId) - .count(); - final addressCount = await getAddresses(walletId).count(); - final utxoCount = await getUTXOs(walletId).count(); - - const paginateLimit = 100; - // transactions - for (int i = 0; i < transactionCount; i += paginateLimit) { - final txnIds = await getTransactions( - walletId, - ).offset(i).limit(paginateLimit).idProperty().findAll(); - await isar.transactions.deleteAll(txnIds); - } - + await getTransactions(walletId).deleteAll(); // transactions V2 - for (int i = 0; i < transactionCountV2; i += paginateLimit) { - final txnIds = await isar.transactionV2s - .where() - .walletIdEqualTo(walletId) - .offset(i) - .limit(paginateLimit) - .idProperty() - .findAll(); - await isar.transactionV2s.deleteAll(txnIds); - } - + await isar.transactionV2s.where().walletIdEqualTo(walletId).deleteAll(); // addresses - for (int i = 0; i < addressCount; i += paginateLimit) { - final addressIds = await getAddresses( - walletId, - ).offset(i).limit(paginateLimit).idProperty().findAll(); - await isar.addresses.deleteAll(addressIds); - } - + await getAddresses(walletId).deleteAll(); // utxos - for (int i = 0; i < utxoCount; i += paginateLimit) { - final utxoIds = await getUTXOs( - walletId, - ).offset(i).limit(paginateLimit).idProperty().findAll(); - await isar.utxos.deleteAll(utxoIds); - } - + await getUTXOs(walletId).deleteAll(); // spark coins await isar.sparkCoins .where() @@ -518,28 +480,14 @@ class MainDB { } Future deleteAddressLabels(String walletId) async { - final addressLabelCount = await getAddressLabels(walletId).count(); await isar.writeTxn(() async { - const paginateLimit = 50; - for (int i = 0; i < addressLabelCount; i += paginateLimit) { - final labelIds = await getAddressLabels( - walletId, - ).offset(i).limit(paginateLimit).idProperty().findAll(); - await isar.addressLabels.deleteAll(labelIds); - } + await getAddressLabels(walletId).deleteAll(); }); } Future deleteTransactionNotes(String walletId) async { - final noteCount = await getTransactionNotes(walletId).count(); await isar.writeTxn(() async { - const paginateLimit = 50; - for (int i = 0; i < noteCount; i += paginateLimit) { - final labelIds = await getTransactionNotes( - walletId, - ).offset(i).limit(paginateLimit).idProperty().findAll(); - await isar.transactionNotes.deleteAll(labelIds); - } + await getTransactionNotes(walletId).deleteAll(); }); } diff --git a/lib/db/queries/queries.dart b/lib/db/queries/queries.dart index c8bbe73850..89f96fa613 100644 --- a/lib/db/queries/queries.dart +++ b/lib/db/queries/queries.dart @@ -42,12 +42,11 @@ extension MainDBQueries on MainDB { required CCFilter filter, required CCSortDescriptor sort, required String searchTerm, + required String locale, required CryptoCurrency cryptoCurrency, }) { var preSort = getUTXOs(walletId).filter().group((q) { - final qq = q.group( - (q) => q.usedIsNull().or().usedEqualTo(false), - ); + final qq = q.group((q) => q.usedIsNull().or().usedEqualTo(false)); switch (filter) { case CCFilter.frozen: return qq.and().isBlockedEqualTo(true); @@ -59,39 +58,36 @@ extension MainDBQueries on MainDB { }); if (searchTerm.isNotEmpty) { - preSort = preSort.and().group( - (q) { - var qq = q.addressContains(searchTerm, caseSensitive: false); - - qq = qq.or().nameContains(searchTerm, caseSensitive: false); - qq = qq.or().group( - (q) => q - .isBlockedEqualTo(true) - .and() - .blockedReasonContains(searchTerm, caseSensitive: false), - ); - - qq = qq.or().txidContains(searchTerm, caseSensitive: false); - qq = qq.or().blockHashContains(searchTerm, caseSensitive: false); - - final maybeDecimal = Decimal.tryParse(searchTerm); - if (maybeDecimal != null) { - qq = qq.or().valueEqualTo( - Amount.fromDecimal( - maybeDecimal, - fractionDigits: cryptoCurrency.fractionDigits, - ).raw.toInt(), - ); - } - - final maybeInt = int.tryParse(searchTerm); - if (maybeInt != null) { - qq = qq.or().valueEqualTo(maybeInt); - } - - return qq; - }, - ); + preSort = preSort.and().group((q) { + var qq = q.addressContains(searchTerm, caseSensitive: false); + + qq = qq.or().nameContains(searchTerm, caseSensitive: false); + qq = qq.or().group( + (q) => q + .isBlockedEqualTo(true) + .and() + .blockedReasonContains(searchTerm, caseSensitive: false), + ); + + qq = qq.or().txidContains(searchTerm, caseSensitive: false); + qq = qq.or().blockHashContains(searchTerm, caseSensitive: false); + + final maybeAmount = Amount.tryParseEditableAmount( + searchTerm, + locale: locale, + fractionDigits: cryptoCurrency.fractionDigits, + ); + if (maybeAmount != null) { + qq = qq.or().valueEqualTo(maybeAmount.raw.toInt()); + } + + final maybeInt = int.tryParse(searchTerm); + if (maybeInt != null) { + qq = qq.or().valueEqualTo(maybeInt); + } + + return qq; + }); } final List ids; @@ -114,12 +110,11 @@ extension MainDBQueries on MainDB { required CCFilter filter, required CCSortDescriptor sort, required String searchTerm, + required String locale, required CryptoCurrency cryptoCurrency, }) { var preSort = getUTXOs(walletId).filter().group((q) { - final qq = q.group( - (q) => q.usedIsNull().or().usedEqualTo(false), - ); + final qq = q.group((q) => q.usedIsNull().or().usedEqualTo(false)); switch (filter) { case CCFilter.frozen: return qq.and().isBlockedEqualTo(true); @@ -131,39 +126,36 @@ extension MainDBQueries on MainDB { }); if (searchTerm.isNotEmpty) { - preSort = preSort.and().group( - (q) { - var qq = q.addressContains(searchTerm, caseSensitive: false); - - qq = qq.or().nameContains(searchTerm, caseSensitive: false); - qq = qq.or().group( - (q) => q - .isBlockedEqualTo(true) - .and() - .blockedReasonContains(searchTerm, caseSensitive: false), - ); - - qq = qq.or().txidContains(searchTerm, caseSensitive: false); - qq = qq.or().blockHashContains(searchTerm, caseSensitive: false); - - final maybeDecimal = Decimal.tryParse(searchTerm); - if (maybeDecimal != null) { - qq = qq.or().valueEqualTo( - Amount.fromDecimal( - maybeDecimal, - fractionDigits: cryptoCurrency.fractionDigits, - ).raw.toInt(), - ); - } - - final maybeInt = int.tryParse(searchTerm); - if (maybeInt != null) { - qq = qq.or().valueEqualTo(maybeInt); - } - - return qq; - }, - ); + preSort = preSort.and().group((q) { + var qq = q.addressContains(searchTerm, caseSensitive: false); + + qq = qq.or().nameContains(searchTerm, caseSensitive: false); + qq = qq.or().group( + (q) => q + .isBlockedEqualTo(true) + .and() + .blockedReasonContains(searchTerm, caseSensitive: false), + ); + + qq = qq.or().txidContains(searchTerm, caseSensitive: false); + qq = qq.or().blockHashContains(searchTerm, caseSensitive: false); + + final maybeAmount = Amount.tryParseEditableAmount( + searchTerm, + locale: locale, + fractionDigits: cryptoCurrency.fractionDigits, + ); + if (maybeAmount != null) { + qq = qq.or().valueEqualTo(maybeAmount.raw.toInt()); + } + + final maybeInt = int.tryParse(searchTerm); + if (maybeInt != null) { + qq = qq.or().valueEqualTo(maybeInt); + } + + return qq; + }); } final List utxos; diff --git a/lib/db/special_migrations.dart b/lib/db/special_migrations.dart index 84b17518cd..c1d6f26604 100644 --- a/lib/db/special_migrations.dart +++ b/lib/db/special_migrations.dart @@ -43,7 +43,9 @@ abstract class CampfireMigration { final myHive = HiveImpl(); myHive.init(appDirectory.path); _wallets = await myHive.openBox('wallets'); - _secureStore = const FlutterSecureStorage(); + _secureStore = const FlutterSecureStorage( + aOptions: AndroidOptions(resetOnError: false, migrateWithBackup: true), + ); } else { await setDidRun(); } diff --git a/lib/electrumx_rpc/electrumx_client.dart b/lib/electrumx_rpc/electrumx_client.dart index 94b650b103..7f7ade19bd 100644 --- a/lib/electrumx_rpc/electrumx_client.dart +++ b/lib/electrumx_rpc/electrumx_client.dart @@ -210,8 +210,9 @@ class ElectrumXClient { Future _allow() async { if (_prefs.wifiOnly) { - return (await Connectivity().checkConnectivity()) == - ConnectivityResult.wifi; + return (await Connectivity().checkConnectivity()).contains( + ConnectivityResult.wifi, + ); } return true; } @@ -519,7 +520,11 @@ class ElectrumXClient { /// Ping the server to ensure it is responding /// /// Returns true if ping succeeded - Future ping({String? requestID, int retryCount = 1}) async { + Future ping({ + String? requestID, + int retryCount = 1, + Duration timeout = const Duration(seconds: 30), + }) async { try { // This doesn't work because electrum_adapter only returns the result: // (which is always `null`). @@ -535,14 +540,15 @@ class ElectrumXClient { return await request( requestID: requestID, command: 'server.ping', - requestTimeout: const Duration(seconds: 30), + requestTimeout: timeout, retries: retryCount, ).timeout( - const Duration(seconds: 30), + timeout, onTimeout: () { Logging.instance.d( "ElectrumxClient.ping timed out with retryCount=$retryCount, host=$_host", ); + return false; }, ) as bool; diff --git a/lib/main.dart b/lib/main.dart index 89d7decb85..b7eff08ee9 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -303,7 +303,12 @@ void main(List args) async { await DbVersionMigrator().migrate( dbVersion, secureStore: const SecureStorageWrapper( - store: FlutterSecureStorage(), + store: FlutterSecureStorage( + aOptions: AndroidOptions( + resetOnError: false, + migrateWithBackup: true, + ), + ), isDesktop: false, ), ); diff --git a/lib/models/exchange/incomplete_exchange.dart b/lib/models/exchange/incomplete_exchange.dart index 86441bc90e..28e0b06c3f 100644 --- a/lib/models/exchange/incomplete_exchange.dart +++ b/lib/models/exchange/incomplete_exchange.dart @@ -28,6 +28,10 @@ class IncompleteExchangeModel extends ChangeNotifier { final Decimal sendAmount; final Decimal receiveAmount; + String get payInAmount => trade?.payInAmount ?? sendAmount.toString(); + + Decimal? get payInDecimal => Decimal.tryParse(payInAmount); + final ExchangeRateType rateType; final bool reversed; @@ -55,6 +59,28 @@ class IncompleteExchangeModel extends ChangeNotifier { } } + String? _extraId; + + String? get extraId => _extraId; + + set extraId(String? extraId) { + if (_extraId != extraId) { + _extraId = extraId; + notifyListeners(); + } + } + + String? _refundExtraId; + + String? get refundExtraId => _refundExtraId; + + set refundExtraId(String? refundExtraId) { + if (_refundExtraId != refundExtraId) { + _refundExtraId = refundExtraId; + notifyListeners(); + } + } + Estimate? _estimate; Estimate? get estimate => _estimate; diff --git a/lib/models/isar/models/blockchain_data/utxo.dart b/lib/models/isar/models/blockchain_data/utxo.dart index 988a713aeb..871b553645 100644 --- a/lib/models/isar/models/blockchain_data/utxo.dart +++ b/lib/models/isar/models/blockchain_data/utxo.dart @@ -86,12 +86,10 @@ class UTXO { int? overrideMinConfirms, // added to handle namecoin name op outputs }) { final confirmations = getConfirmations(currentChainHeight); - - if (overrideMinConfirms != null) { - return confirmations >= overrideMinConfirms; - } - return confirmations >= + final requiredConfirmations = + overrideMinConfirms ?? (isCoinbase ? minimumCoinbaseConfirms : minimumConfirms); + return confirmations >= max(requiredConfirmations, mwebPegoutMaturity ?? 0); } /// A lingering [blockedReason] on an unblocked utxo means the wallet @@ -107,6 +105,24 @@ class UTXO { return keyImage != null; } + @ignore + int? get mwebPegoutMaturity { + if (otherData == null) { + return null; + } + + try { + final value = + (jsonDecode(otherData!) as Map)[UTXOOtherDataKeys.mwebPegoutMaturity]; + return value is int && value > 0 ? value : null; + } catch (_) { + return null; + } + } + + @ignore + bool get isMwebPegout => mwebPegoutMaturity != null; + @ignore String? get keyImage { if (otherData == null) { @@ -189,6 +205,7 @@ class UTXO { abstract final class UTXOOtherDataKeys { static const keyImage = "keyImage"; + static const mwebPegoutMaturity = "mwebPegoutMaturity"; static const spent = "spent"; static const nameOpData = "nameOpData"; } diff --git a/lib/models/node_model.dart b/lib/models/node_model.dart index 5386cae0f9..85c02c48a3 100644 --- a/lib/models/node_model.dart +++ b/lib/models/node_model.dart @@ -69,6 +69,54 @@ class NodeModel { this.nodeApiSecret, }); + factory NodeModel.fromStackBackup( + Map map, { + Set? legacyPrimaryNodeIds, + }) { + final id = map['id'] as String; + return NodeModel( + host: map['host'] as String, + port: map['port'] as int, + name: map['name'] as String, + id: id, + useSSL: _backupBool(map['useSSL'], fallback: true), + loginName: map['loginName'] as String?, + enabled: _backupBool(map['enabled'], fallback: true), + coinName: map['coinName'] as String, + isFailover: _backupBool(map['isFailover'], fallback: false), + isDown: _backupBool(map['isDown'], fallback: false), + trusted: _nullableBackupBool(map['trusted']), + torEnabled: _backupBool(map['torEnabled'], fallback: true), + clearnetEnabled: _backupBool( + map['clearEnabled'] ?? map['plainEnabled'], + fallback: true, + ), + forceNoTor: _backupBool(map['forceNoTor'], fallback: false), + isPrimary: _backupBool( + map['isPrimary'], + fallback: legacyPrimaryNodeIds?.contains(id) ?? false, + ), + nodeApiSecret: map['nodeApiSecret'] as String?, + ); + } + + static bool _backupBool(Object? value, {required bool fallback}) => + _nullableBackupBool(value) ?? fallback; + + static bool? _nullableBackupBool(Object? value) { + if (value is bool) { + return value; + } + if (value is String) { + return switch (value.trim().toLowerCase()) { + 'true' => true, + 'false' => false, + _ => null, + }; + } + return null; + } + NodeModel copyWith({ String? host, int? port, diff --git a/lib/pages/buy_view/buy_form.dart b/lib/pages/buy_view/buy_form.dart index 93b64400d4..48aa3b753d 100644 --- a/lib/pages/buy_view/buy_form.dart +++ b/lib/pages/buy_view/buy_form.dart @@ -31,6 +31,9 @@ import '../../services/buy/buy_response.dart'; import '../../services/buy/simplex/simplex_api.dart'; import '../../themes/stack_colors.dart'; import '../../utilities/address_utils.dart'; +import '../../utilities/amount/amount.dart'; +import '../../utilities/amount/amount_field_relocalization.dart'; +import '../../utilities/amount/amount_input_formatter.dart'; import '../../utilities/assets.dart'; import '../../utilities/barcode_scanner_interface.dart'; import '../../utilities/clipboard_interface.dart'; @@ -125,6 +128,13 @@ class _BuyFormState extends ConsumerState { // static String boundedCryptoTicker = ''; String _amountOutOfRangeErrorString = ""; + + Decimal? get _parsedBuyAmount => Amount.tryParseEditableAmount( + _buyAmountController.text, + locale: ref.read(localeServiceChangeNotifierProvider).locale, + fractionDigits: buyWithFiat ? 2 : 30, + )?.decimal; + void validateAmount() { if (_buyAmountController.text.isEmpty) { setState(() { @@ -133,20 +143,30 @@ class _BuyFormState extends ConsumerState { return; } - final value = Decimal.tryParse(_buyAmountController.text); + final value = _parsedBuyAmount; if (value == null) { setState(() { _amountOutOfRangeErrorString = "Invalid amount"; }); } else if (value > maxFiat && buyWithFiat) { + final locale = ref.read(localeServiceChangeNotifierProvider).locale; + final maximum = Amount.formatFixedDecimal( + maxFiat, + fractionDigits: 2, + locale: locale, + ); setState(() { - _amountOutOfRangeErrorString = - "Maximum amount: ${maxFiat.toStringAsFixed(2)}"; + _amountOutOfRangeErrorString = "Maximum amount: $maximum"; }); } else if (value < minFiat && buyWithFiat) { + final locale = ref.read(localeServiceChangeNotifierProvider).locale; + final minimum = Amount.formatFixedDecimal( + minFiat, + fractionDigits: 2, + locale: locale, + ); setState(() { - _amountOutOfRangeErrorString = - "Minimum amount: ${minFiat.toStringAsFixed(2)}"; + _amountOutOfRangeErrorString = "Minimum amount: $minimum"; }); } else { setState(() { @@ -396,6 +416,11 @@ class _BuyFormState extends ConsumerState { // } Future previewQuote(SimplexQuote quote) async { + final buyAmount = _parsedBuyAmount; + if (buyAmount == null) { + return; + } + bool shouldPop = false; unawaited( showDialog( @@ -414,11 +439,11 @@ class _BuyFormState extends ConsumerState { crypto: selectedCrypto!, fiat: selectedFiat!, youPayFiatPrice: buyWithFiat - ? Decimal.parse(_buyAmountController.text) + ? buyAmount : Decimal.parse("100"), // dummy value youReceiveCryptoAmount: buyWithFiat ? Decimal.parse("0.000420282") // dummy value - : Decimal.parse(_buyAmountController.text), // Ternary for this + : buyAmount, id: "id", // anything; we get an ID back receivingAddress: _receiveAddressController.text, buyWithFiat: buyWithFiat, @@ -795,8 +820,23 @@ class _BuyFormState extends ConsumerState { Widget build(BuildContext context) { debugPrint("BUILD: $runtimeType"); - final Locale locale = Localizations.localeOf(context); - final format = NumberFormat.simpleCurrency(locale: locale.toString()); + final locale = ref.watch( + localeServiceChangeNotifierProvider.select((value) => value.locale), + ); + listenForAmountRelocalization( + ref.listen, + controllers: [_buyAmountController], + onRelocalized: validateAmount, + ); + // intl throws ArgumentError for device locales it has no data for + // (e.g. yo, ig, mi); this is the raw device locale, not one resolved + // against supported locales. + NumberFormat format; + try { + format = NumberFormat.simpleCurrency(locale: locale); + } on ArgumentError { + format = NumberFormat.simpleCurrency(locale: "en_US"); + } // See https://stackoverflow.com/a/67055685 return ConditionalParent( @@ -1013,7 +1053,13 @@ class _BuyFormState extends ConsumerState { decimal: true, ), textAlign: TextAlign.left, - // inputFormatters: [NumericalRangeFormatter()], + inputFormatters: [ + AmountInputFormatter( + controller: _buyAmountController, + decimals: buyWithFiat ? 2 : 30, + locale: locale, + ), + ], onChanged: (_) { validateAmount(); }, @@ -1123,12 +1169,17 @@ class _BuyFormState extends ConsumerState { final ClipboardData? data = await clipboard .getData(Clipboard.kTextPlain); - final amountString = Decimal.tryParse( + final amount = Amount.tryParseEditableAmount( data?.text ?? "", + locale: locale, + fractionDigits: buyWithFiat ? 2 : 30, ); - if (amountString != null) { - _buyAmountController.text = amountString - .toString(); + if (amount != null) { + _buyAmountController.text = + Amount.formatEditableDecimal( + amount.decimal, + locale: locale, + ); validateAmount(); } diff --git a/lib/pages/cakepay/cakepay_card_detail_view.dart b/lib/pages/cakepay/cakepay_card_detail_view.dart index 09a205c45e..d11437b976 100644 --- a/lib/pages/cakepay/cakepay_card_detail_view.dart +++ b/lib/pages/cakepay/cakepay_card_detail_view.dart @@ -1,10 +1,15 @@ import 'package:decimal/decimal.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../providers/global/locale_provider.dart'; import '../../services/cakepay/cakepay_service.dart'; import '../../services/cakepay/src/models/card.dart'; import '../../themes/stack_colors.dart'; +import '../../utilities/amount/amount.dart'; +import '../../utilities/amount/amount_field_relocalization.dart'; +import '../../utilities/amount/amount_input_formatter.dart'; import '../../utilities/text_styles.dart'; import '../../utilities/util.dart'; import '../../widgets/background.dart'; @@ -21,7 +26,7 @@ import '../../widgets/stack_dialog.dart'; import '../../widgets/textfields/adaptive_text_field.dart'; import 'cakepay_order_view.dart'; -class CakePayCardDetailView extends StatefulWidget { +class CakePayCardDetailView extends ConsumerStatefulWidget { const CakePayCardDetailView({super.key, required this.card}); static const String routeName = "/cakePayCardDetail"; @@ -29,10 +34,11 @@ class CakePayCardDetailView extends StatefulWidget { final CakePayCard card; @override - State createState() => _CakePayCardDetailViewState(); + ConsumerState createState() => + _CakePayCardDetailViewState(); } -class _CakePayCardDetailViewState extends State { +class _CakePayCardDetailViewState extends ConsumerState { late CakePayCard _card; bool _purchasing = false; Decimal? _selectedDenomination; @@ -52,23 +58,24 @@ class _CakePayCardDetailViewState extends State { } } - String get _priceString { + Decimal? get _price { if (_card.isFixedDenomination && _selectedDenomination != null) { - return _selectedDenomination!.toStringAsFixed(2); + return _selectedDenomination; } - return _customAmountController.text.trim(); + return Amount.tryParseFiatString( + _customAmountController.text, + locale: ref.read(localeServiceChangeNotifierProvider).locale, + )?.decimal; } bool _checkCanPurchase() { if (!_termsAccepted || _purchasing) return false; if (_emailController.text.trim().isEmpty) return false; - final price = _priceString; - if (price.isEmpty) return false; - final parsed = Decimal.tryParse(price); - if (parsed == null || parsed <= Decimal.zero) return false; + final price = _price; + if (price == null || price <= Decimal.zero) return false; if (_card.isRangeDenomination) { - if (_card.minValue != null && parsed < _card.minValue!) return false; - if (_card.maxValue != null && parsed > _card.maxValue!) return false; + if (_card.minValue != null && price < _card.minValue!) return false; + if (_card.maxValue != null && price > _card.maxValue!) return false; } return true; } @@ -80,11 +87,13 @@ class _CakePayCardDetailViewState extends State { Future _purchase() async { if (!_checkCanPurchase()) return; + final price = _price; + if (price == null) return; setState(() => _purchasing = true); final resp = await CakePayService.instance.client.createOrder( cardId: _card.id, - price: _priceString, + price: price.toStringAsFixed(2), quantity: _quantity > 1 ? _quantity : null, userEmail: _emailController.text.trim(), confirmsNoVpn: true, @@ -150,6 +159,14 @@ class _CakePayCardDetailViewState extends State { Widget build(BuildContext context) { final isDesktop = Util.isDesktop; final card = _card; + final locale = ref.watch( + localeServiceChangeNotifierProvider.select((value) => value.locale), + ); + listenForAmountRelocalization( + ref.listen, + controllers: [_customAmountController], + onRelocalized: _updateCanPurchase, + ); return ConditionalParent( condition: isDesktop, @@ -250,6 +267,7 @@ class _CakePayCardDetailViewState extends State { _DenominationSelector( card: card, isDesktop: isDesktop, + locale: locale, selectedDenomination: _selectedDenomination, customAmountController: _customAmountController, onDenominationSelected: (Decimal d) { @@ -414,6 +432,7 @@ class _DenominationSelector extends StatelessWidget { const _DenominationSelector({ required this.card, required this.isDesktop, + required this.locale, required this.selectedDenomination, required this.customAmountController, required this.onDenominationSelected, @@ -422,6 +441,7 @@ class _DenominationSelector extends StatelessWidget { final CakePayCard card; final bool isDesktop; + final String locale; final Decimal? selectedDenomination; final TextEditingController customAmountController; final ValueChanged onDenominationSelected; @@ -429,6 +449,10 @@ class _DenominationSelector extends StatelessWidget { @override Widget build(BuildContext context) { + String formatPrice(Decimal? price) => price == null + ? "?" + : Amount.formatFixedDecimal(price, fractionDigits: 2, locale: locale); + if (card.isFixedDenomination) { return Wrap( spacing: 8, @@ -437,7 +461,7 @@ class _DenominationSelector extends StatelessWidget { final bool selected = d == selectedDenomination; return ChoiceChip( label: Text( - "${d.toStringAsFixed(2)} ${card.currencyCode ?? ''}", + "${formatPrice(d)} ${card.currencyCode ?? ''}", style: (isDesktop ? STextStyles.desktopTextExtraExtraSmall(context) @@ -465,8 +489,8 @@ class _DenominationSelector extends StatelessWidget { mainAxisSize: .min, children: [ Text( - "Enter amount (${card.minValue?.toStringAsFixed(2) ?? '?'} - " - "${card.maxValue?.toStringAsFixed(2) ?? '?'} " + "Enter amount (${formatPrice(card.minValue)} - " + "${formatPrice(card.maxValue)} " "${card.currencyCode ?? ''})", style: isDesktop ? STextStyles.desktopTextExtraExtraSmall(context) @@ -477,6 +501,13 @@ class _DenominationSelector extends StatelessWidget { labelText: "Amount", controller: customAmountController, keyboardType: const .numberWithOptions(decimal: true), + inputFormatters: [ + AmountInputFormatter( + controller: customAmountController, + decimals: 2, + locale: locale, + ), + ], onChangedComprehensive: (_) => onCustomAmountChanged(), ), ], diff --git a/lib/pages/cakepay/cakepay_order_view.dart b/lib/pages/cakepay/cakepay_order_view.dart index 4232a4edac..c805e1c0a9 100644 --- a/lib/pages/cakepay/cakepay_order_view.dart +++ b/lib/pages/cakepay/cakepay_order_view.dart @@ -816,7 +816,7 @@ class _CakePayOrderViewState extends ConsumerState { : STextStyles.itemSubtitle12(context), ), const Spacer(), - IconCopyButton(data: order.orderId), + IconCopyButton(data: selected.address), const SizedBox(width: 4), Text("Copy", style: STextStyles.link2(context)), ], diff --git a/lib/pages/coin_control/coin_control_view.dart b/lib/pages/coin_control/coin_control_view.dart index 7960733643..730001ccce 100644 --- a/lib/pages/coin_control/coin_control_view.dart +++ b/lib/pages/coin_control/coin_control_view.dart @@ -18,6 +18,7 @@ import 'package:tuple/tuple.dart'; import '../../db/isar/main_db.dart'; import '../../models/isar/models/isar_models.dart'; +import '../../providers/global/locale_provider.dart'; import '../../providers/global/wallets_provider.dart'; import '../../themes/stack_colors.dart'; import '../../utilities/amount/amount.dart'; @@ -123,15 +124,17 @@ class _CoinControlViewState extends ConsumerState { Widget build(BuildContext context) { debugPrint("BUILD: $runtimeType"); - final minConfirms = - ref - .watch(pWallets) - .getWallet(widget.walletId) - .cryptoCurrency - .minConfirms; + final minConfirms = ref + .watch(pWallets) + .getWallet(widget.walletId) + .cryptoCurrency + .minConfirms; final coin = ref.watch(pWalletCoin(widget.walletId)); final currentHeight = ref.watch(pWalletChainHeight(widget.walletId)); + final locale = ref.watch( + localeServiceChangeNotifierProvider.select((value) => value.locale), + ); if (_sort == CCSortDescriptor.address && !_isSearching) { _list = null; @@ -140,20 +143,21 @@ class _CoinControlViewState extends ConsumerState { filter: CCFilter.all, sort: _sort, searchTerm: "", + locale: locale, cryptoCurrency: coin, ); } else { _map = null; _list = MainDB.instance.queryUTXOsSync( walletId: widget.walletId, - filter: - _isSearching - ? CCFilter.all - : _showBlocked - ? CCFilter.frozen - : CCFilter.available, + filter: _isSearching + ? CCFilter.all + : _showBlocked + ? CCFilter.frozen + : CCFilter.available, sort: _sort, searchTerm: _isSearching ? searchController.text : "", + locale: locale, cryptoCurrency: coin, ); } @@ -168,115 +172,107 @@ class _CoinControlViewState extends ConsumerState { }, child: Background( child: Scaffold( - backgroundColor: - Theme.of(context).extension()!.background, + backgroundColor: Theme.of( + context, + ).extension()!.background, appBar: AppBar( automaticallyImplyLeading: false, - leading: - _isSearching - ? null - : widget.type == CoinControlViewType.use && - _selectedAvailable.isNotEmpty - ? AppBarIconButton( - icon: XIcon( - width: 24, - height: 24, - color: - Theme.of( - context, - ).extension()!.topNavIconPrimary, - ), - onPressed: () { - setState(() { - _selectedAvailable.clear(); - }); - }, - ) - : AppBarBackButton( - onPressed: () { - unawaited(_refreshBalance()); - Navigator.of(context).pop( - widget.type == CoinControlViewType.use - ? _selectedAvailable - : null, - ); - }, - ), - title: - _isSearching - ? AppBarSearchField( - controller: searchController, - focusNode: searchFocus, - ) - : Text( - "Coin control", - style: STextStyles.navBarTitle(context), + leading: _isSearching + ? null + : widget.type == CoinControlViewType.use && + _selectedAvailable.isNotEmpty + ? AppBarIconButton( + icon: XIcon( + width: 24, + height: 24, + color: Theme.of( + context, + ).extension()!.topNavIconPrimary, ), + onPressed: () { + setState(() { + _selectedAvailable.clear(); + }); + }, + ) + : AppBarBackButton( + onPressed: () { + unawaited(_refreshBalance()); + Navigator.of(context).pop( + widget.type == CoinControlViewType.use + ? _selectedAvailable + : null, + ); + }, + ), + title: _isSearching + ? AppBarSearchField( + controller: searchController, + focusNode: searchFocus, + ) + : Text("Coin control", style: STextStyles.navBarTitle(context)), titleSpacing: 0, - actions: - _isSearching - ? [ - AspectRatio( - aspectRatio: 1, - child: AppBarIconButton( - size: 36, - icon: SvgPicture.asset( - Assets.svg.x, - width: 20, - height: 20, - color: - Theme.of( - context, - ).extension()!.topNavIconPrimary, - ), - onPressed: () { - // show search - setState(() { - _isSearching = false; - }); - }, + actions: _isSearching + ? [ + AspectRatio( + aspectRatio: 1, + child: AppBarIconButton( + size: 36, + icon: SvgPicture.asset( + Assets.svg.x, + width: 20, + height: 20, + color: Theme.of( + context, + ).extension()!.topNavIconPrimary, ), + onPressed: () { + // show search + setState(() { + _isSearching = false; + }); + }, ), - ] - : [ - AspectRatio( - aspectRatio: 1, - child: AppBarIconButton( - size: 36, - icon: SvgPicture.asset( - Assets.svg.search, - width: 20, - height: 20, - color: - Theme.of( - context, - ).extension()!.topNavIconPrimary, - ), - onPressed: () { - // show search - setState(() { - _isSearching = true; - }); - }, + ), + ] + : [ + AspectRatio( + aspectRatio: 1, + child: AppBarIconButton( + size: 36, + icon: SvgPicture.asset( + Assets.svg.search, + width: 20, + height: 20, + color: Theme.of( + context, + ).extension()!.topNavIconPrimary, ), + onPressed: () { + // show search + setState(() { + _isSearching = true; + }); + }, ), - AspectRatio( - aspectRatio: 1, - child: JDropdownIconButton( - mobileAppBar: true, - groupValue: _sort, - items: CCSortDescriptor.values.toSet(), - onSelectionChanged: (CCSortDescriptor? newValue) { - if (newValue != null && newValue != _sort) { - setState(() { - _sort = newValue; - }); - } - }, - displayPrefix: "Sort by", - ), + ), + AspectRatio( + aspectRatio: 1, + child: JDropdownIconButton( + mobileAppBar: true, + groupValue: _sort, + items: CCSortDescriptor.values.toSet(), + onSelectionChanged: (CCSortDescriptor? newValue) { + if (newValue != null && newValue != _sort) { + setState(() { + _sort = newValue; + }); + } + }, + displayPrefix: "Sort by", ), - ], + ), + ], ), body: SafeArea( child: Column( @@ -294,10 +290,9 @@ class _CoinControlViewState extends ConsumerState { "outputs at your discretion. Tap the output circle to " "select.", style: STextStyles.w500_14(context).copyWith( - color: - Theme.of( - context, - ).extension()!.textSubtitle1, + color: Theme.of( + context, + ).extension()!.textSubtitle1, ), ), ), @@ -307,15 +302,13 @@ class _CoinControlViewState extends ConsumerState { height: 48, child: Toggle( key: UniqueKey(), - onColor: - Theme.of( - context, - ).extension()!.popupBG, + onColor: Theme.of( + context, + ).extension()!.popupBG, onText: "Available outputs", - offColor: - Theme.of(context) - .extension()! - .textFieldDefaultBG, + offColor: Theme.of( + context, + ).extension()!.textFieldDefaultBG, offText: "Frozen outputs", isOn: _showBlocked, onValueChanged: (value) { @@ -336,14 +329,13 @@ class _CoinControlViewState extends ConsumerState { Expanded( child: ListView.separated( itemCount: _list!.length, - separatorBuilder: - (context, _) => const SizedBox(height: 10), + separatorBuilder: (context, _) => + const SizedBox(height: 10), itemBuilder: (context, index) { - final utxo = - MainDB.instance.isar.utxos - .where() - .idEqualTo(_list![index]) - .findFirstSync()!; + final utxo = MainDB.instance.isar.utxos + .where() + .idEqualTo(_list![index]) + .findFirstSync()!; final isSelected = _selectedBlocked.contains(utxo) || @@ -385,15 +377,14 @@ class _CoinControlViewState extends ConsumerState { setState(() {}); }, onPressed: () async { - final result = await Navigator.of( - context, - ).pushNamed( - UtxoDetailsView.routeName, - arguments: Tuple2( - utxo.id, - widget.walletId, - ), - ); + final result = await Navigator.of(context) + .pushNamed( + UtxoDetailsView.routeName, + arguments: Tuple2( + utxo.id, + widget.walletId, + ), + ); if (mounted && result == "refresh") { setState(() {}); } @@ -405,244 +396,236 @@ class _CoinControlViewState extends ConsumerState { if (!_isSearching) _list != null ? Expanded( - child: ListView.separated( - itemCount: _list!.length, - separatorBuilder: - (context, _) => - const SizedBox(height: 10), - itemBuilder: (context, index) { - final utxo = - MainDB.instance.isar.utxos - .where() - .idEqualTo(_list![index]) - .findFirstSync()!; + child: ListView.separated( + itemCount: _list!.length, + separatorBuilder: (context, _) => + const SizedBox(height: 10), + itemBuilder: (context, index) { + final utxo = MainDB.instance.isar.utxos + .where() + .idEqualTo(_list![index]) + .findFirstSync()!; - final isSelected = - _showBlocked - ? _selectedBlocked.contains(utxo) - : _selectedAvailable.contains(utxo); + final isSelected = _showBlocked + ? _selectedBlocked.contains(utxo) + : _selectedAvailable.contains(utxo); - return UtxoCard( - key: Key( - "${utxo.walletId}_${utxo.id}_$isSelected", - ), - walletId: widget.walletId, - utxo: utxo, - canSelect: - widget.type == - CoinControlViewType.manage || - (widget.type == - CoinControlViewType.use && - !_showBlocked && - _isConfirmed( - utxo, - currentHeight, - ref.watch( - pWallets.select( - (s) => s.getWallet( - widget.walletId, + return UtxoCard( + key: Key( + "${utxo.walletId}_${utxo.id}_$isSelected", + ), + walletId: widget.walletId, + utxo: utxo, + canSelect: + widget.type == + CoinControlViewType.manage || + (widget.type == + CoinControlViewType.use && + !_showBlocked && + _isConfirmed( + utxo, + currentHeight, + ref.watch( + pWallets.select( + (s) => s.getWallet( + widget.walletId, + ), ), ), - ), - )), - initialSelectedState: isSelected, - onSelectedChanged: (value) { - if (value) { - _showBlocked - ? _selectedBlocked.add(utxo) - : _selectedAvailable.add(utxo); - } else { - _showBlocked - ? _selectedBlocked.remove(utxo) - : _selectedAvailable.remove(utxo); - } - setState(() {}); - }, - onPressed: () async { - final result = await Navigator.of( - context, - ).pushNamed( - UtxoDetailsView.routeName, - arguments: Tuple2( - utxo.id, - widget.walletId, - ), - ); - if (mounted && result == "refresh") { + )), + initialSelectedState: isSelected, + onSelectedChanged: (value) { + if (value) { + _showBlocked + ? _selectedBlocked.add(utxo) + : _selectedAvailable.add(utxo); + } else { + _showBlocked + ? _selectedBlocked.remove(utxo) + : _selectedAvailable.remove( + utxo, + ); + } setState(() {}); - } - }, - ); - }, - ), - ) + }, + onPressed: () async { + final result = + await Navigator.of( + context, + ).pushNamed( + UtxoDetailsView.routeName, + arguments: Tuple2( + utxo.id, + widget.walletId, + ), + ); + if (mounted && result == "refresh") { + setState(() {}); + } + }, + ); + }, + ), + ) : Expanded( - child: ListView.separated( - itemCount: _map!.entries.length, - separatorBuilder: - (context, _) => - const SizedBox(height: 10), - itemBuilder: (context, index) { - final entry = _map!.entries.elementAt( - index, - ); - final _controller = RotateIconController(); + child: ListView.separated( + itemCount: _map!.entries.length, + separatorBuilder: (context, _) => + const SizedBox(height: 10), + itemBuilder: (context, index) { + final entry = _map!.entries.elementAt( + index, + ); + final _controller = + RotateIconController(); - return Expandable2( - border: - Theme.of(context) - .extension()! - .backgroundAppBar, - background: - Theme.of( - context, - ).extension()!.popupBG, - animationDurationMultiplier: - 0.2 * entry.value.length, - onExpandWillChange: (state) { - if (state == - Expandable2State.expanded) { - _controller.forward?.call(); - } else { - _controller.reverse?.call(); - } - }, - header: RoundedContainer( - padding: const EdgeInsets.all(14), - color: Colors.transparent, - child: Row( - children: [ - Expanded( - child: Column( - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - Text( - entry.key, - style: STextStyles.w600_14( - context, + return Expandable2( + border: Theme.of(context) + .extension()! + .backgroundAppBar, + background: Theme.of( + context, + ).extension()!.popupBG, + animationDurationMultiplier: + 0.2 * entry.value.length, + onExpandWillChange: (state) { + if (state == + Expandable2State.expanded) { + _controller.forward?.call(); + } else { + _controller.reverse?.call(); + } + }, + header: RoundedContainer( + padding: const EdgeInsets.all(14), + color: Colors.transparent, + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Text( + entry.key, + style: + STextStyles.w600_14( + context, + ), ), - ), - const SizedBox(height: 2), - Text( - "${entry.value.length} " - "output${entry.value.length > 1 ? "s" : ""}", - style: STextStyles.w500_12( - context, - ).copyWith( - color: - Theme.of(context) - .extension< - StackColors - >()! - .textSubtitle1, + const SizedBox(height: 2), + Text( + "${entry.value.length} " + "output${entry.value.length > 1 ? "s" : ""}", + style: + STextStyles.w500_12( + context, + ).copyWith( + color: Theme.of(context) + .extension< + StackColors + >()! + .textSubtitle1, + ), ), - ), - ], + ], + ), ), - ), - RotateIcon( - animationDurationMultiplier: - 0.2 * entry.value.length, - icon: SvgPicture.asset( - Assets.svg.chevronDown, - width: 14, - color: - Theme.of(context) - .extension< - StackColors - >()! - .textSubtitle1, + RotateIcon( + animationDurationMultiplier: + 0.2 * entry.value.length, + icon: SvgPicture.asset( + Assets.svg.chevronDown, + width: 14, + color: Theme.of(context) + .extension()! + .textSubtitle1, + ), + curve: Curves.easeInOut, + controller: _controller, ), - curve: Curves.easeInOut, - controller: _controller, - ), - ], + ], + ), ), - ), - children: - entry.value.map((id) { - final utxo = - MainDB.instance.isar.utxos - .where() - .idEqualTo(id) - .findFirstSync()!; + children: entry.value.map((id) { + final utxo = MainDB + .instance + .isar + .utxos + .where() + .idEqualTo(id) + .findFirstSync()!; - final isSelected = - _selectedBlocked.contains( - utxo, - ) || - _selectedAvailable.contains( - utxo, - ); + final isSelected = + _selectedBlocked.contains(utxo) || + _selectedAvailable.contains(utxo); - return UtxoCard( - key: Key( - "${utxo.walletId}_${utxo.id}_$isSelected", - ), - walletId: widget.walletId, - utxo: utxo, - canSelect: - widget.type == - CoinControlViewType - .manage || - (widget.type == - CoinControlViewType - .use && - !utxo.isBlocked && - _isConfirmed( - utxo, - currentHeight, - ref.watch( - pWallets.select( - (s) => s.getWallet( - widget.walletId, - ), + return UtxoCard( + key: Key( + "${utxo.walletId}_${utxo.id}_$isSelected", + ), + walletId: widget.walletId, + utxo: utxo, + canSelect: + widget.type == + CoinControlViewType + .manage || + (widget.type == + CoinControlViewType + .use && + !utxo.isBlocked && + _isConfirmed( + utxo, + currentHeight, + ref.watch( + pWallets.select( + (s) => s.getWallet( + widget.walletId, ), ), - )), - initialSelectedState: isSelected, - onSelectedChanged: (value) { - if (value) { - utxo.isBlocked - ? _selectedBlocked.add( - utxo, - ) - : _selectedAvailable.add( + ), + )), + initialSelectedState: isSelected, + onSelectedChanged: (value) { + if (value) { + utxo.isBlocked + ? _selectedBlocked.add(utxo) + : _selectedAvailable.add( utxo, ); - } else { - utxo.isBlocked - ? _selectedBlocked.remove( + } else { + utxo.isBlocked + ? _selectedBlocked.remove( utxo, ) - : _selectedAvailable - .remove(utxo); - } + : _selectedAvailable.remove( + utxo, + ); + } + setState(() {}); + }, + onPressed: () async { + final result = + await Navigator.of( + context, + ).pushNamed( + UtxoDetailsView.routeName, + arguments: Tuple2( + utxo.id, + widget.walletId, + ), + ); + if (mounted && + result == "refresh") { setState(() {}); - }, - onPressed: () async { - final result = - await Navigator.of( - context, - ).pushNamed( - UtxoDetailsView.routeName, - arguments: Tuple2( - utxo.id, - widget.walletId, - ), - ); - if (mounted && - result == "refresh") { - setState(() {}); - } - }, - ); - }).toList(), - ); - }, + } + }, + ); + }).toList(), + ); + }, + ), ), - ), ], ), ), @@ -652,10 +635,9 @@ class _CoinControlViewState extends ConsumerState { widget.type == CoinControlViewType.manage) Container( decoration: BoxDecoration( - color: - Theme.of( - context, - ).extension()!.backgroundAppBar, + color: Theme.of( + context, + ).extension()!.backgroundAppBar, boxShadow: [ Theme.of( context, @@ -690,10 +672,9 @@ class _CoinControlViewState extends ConsumerState { if (!_showBlocked && widget.type == CoinControlViewType.use) Container( decoration: BoxDecoration( - color: - Theme.of( - context, - ).extension()!.backgroundAppBar, + color: Theme.of( + context, + ).extension()!.backgroundAppBar, boxShadow: [ Theme.of( context, @@ -722,13 +703,13 @@ class _CoinControlViewState extends ConsumerState { builder: (context) { final int selectedSumInt = _selectedAvailable.isEmpty - ? 0 - : _selectedAvailable - .map((e) => e.value) - .reduce( - (value, element) => - value += element, - ); + ? 0 + : _selectedAvailable + .map((e) => e.value) + .reduce( + (value, element) => + value += element, + ); final selectedSum = selectedSumInt .toAmountAsRaw( fractionDigits: @@ -738,33 +719,26 @@ class _CoinControlViewState extends ConsumerState { ref .watch(pAmountFormatter(coin)) .format(selectedSum), - style: - widget.requestedTotal == null - ? STextStyles.w600_14( - context, - ) - : STextStyles.w600_14( - context, - ).copyWith( - color: - selectedSum >= - widget - .requestedTotal! - ? Theme.of( - context, - ) - .extension< - StackColors - >()! - .accentColorGreen - : Theme.of( - context, - ) - .extension< - StackColors - >()! - .accentColorRed, - ), + style: widget.requestedTotal == null + ? STextStyles.w600_14(context) + : STextStyles.w600_14( + context, + ).copyWith( + color: + selectedSum >= + widget + .requestedTotal! + ? Theme.of(context) + .extension< + StackColors + >()! + .accentColorGreen + : Theme.of(context) + .extension< + StackColors + >()! + .accentColorRed, + ), ); }, ), @@ -775,10 +749,9 @@ class _CoinControlViewState extends ConsumerState { Container( width: double.infinity, height: 1.5, - color: - Theme.of(context) - .extension()! - .backgroundAppBar, + color: Theme.of(context) + .extension()! + .backgroundAppBar, ), if (widget.requestedTotal != null) Padding( diff --git a/lib/pages/exchange_view/exchange_form.dart b/lib/pages/exchange_view/exchange_form.dart index c328e5e4f7..4acf842cb2 100644 --- a/lib/pages/exchange_view/exchange_form.dart +++ b/lib/pages/exchange_view/exchange_form.dart @@ -37,7 +37,8 @@ import '../../services/exchange/nanswap/nanswap_exchange.dart'; import '../../services/exchange/trocador/trocador_exchange.dart'; import '../../services/exchange/wizard_swap/wizard_swap_exchange.dart'; import '../../themes/stack_colors.dart'; -import '../../utilities/amount/amount_unit.dart'; +import '../../utilities/amount/amount.dart'; +import '../../utilities/amount/amount_field_relocalization.dart'; import '../../utilities/assets.dart'; import '../../utilities/constants.dart'; import '../../utilities/enums/exchange_rate_type_enum.dart'; @@ -105,6 +106,10 @@ class _ExchangeFormState extends ConsumerState { // todo: check and adjust this value? static const _valueCheckInterval = Duration(milliseconds: 1500); + String? _pendingSendAmountText; + String? _pendingReceiveAmountText; + late String _amountInputLocale; + Future showUpdatingExchangeRate({ required Future whileFuture, }) async { @@ -140,9 +145,13 @@ class _ExchangeFormState extends ConsumerState { void sendFieldOnChanged(String value) { if (_sendFocusNode.hasFocus) { _sendFieldOnChangedTimer?.cancel(); + _pendingSendAmountText = value; _sendFieldOnChangedTimer = Timer(_valueCheckInterval, () async { - final newFromAmount = _localizedStringToNum(value); + final pendingText = _pendingSendAmountText ?? value; + _pendingSendAmountText = null; + _sendFieldOnChangedTimer = null; + final newFromAmount = _localizedStringToNum(pendingText); ref.read(efSendAmountProvider.notifier).state = newFromAmount; if (!_swapLock && !ref.read(efReversedProvider)) { @@ -155,9 +164,13 @@ class _ExchangeFormState extends ConsumerState { Timer? _receiveFieldOnChangedTimer; void receiveFieldOnChanged(String value) async { _receiveFieldOnChangedTimer?.cancel(); + _pendingReceiveAmountText = value; _receiveFieldOnChangedTimer = Timer(_valueCheckInterval, () async { - final newToAmount = _localizedStringToNum(value); + final pendingText = _pendingReceiveAmountText ?? value; + _pendingReceiveAmountText = null; + _receiveFieldOnChangedTimer = null; + final newToAmount = _localizedStringToNum(pendingText); ref.read(efReceiveAmountProvider.notifier).state = newToAmount; if (!_swapLock && ref.read(efReversedProvider)) { @@ -166,21 +179,72 @@ class _ExchangeFormState extends ConsumerState { }); } + bool _flushPendingAmountChange() { + final flushSend = _sendFieldOnChangedTimer?.isActive ?? false; + final flushReceive = _receiveFieldOnChangedTimer?.isActive ?? false; + String? sendText; + String? receiveText; + + // Capture both edits before publishing either provider state. A provider + // refresh may rewrite either controller once the first state is visible. + if (flushSend) { + _sendFieldOnChangedTimer!.cancel(); + _sendFieldOnChangedTimer = null; + sendText = _pendingSendAmountText ?? _sendController.text; + _pendingSendAmountText = null; + } + if (flushReceive) { + _receiveFieldOnChangedTimer!.cancel(); + _receiveFieldOnChangedTimer = null; + receiveText = _pendingReceiveAmountText ?? _receiveController.text; + _pendingReceiveAmountText = null; + } + + if (flushSend) { + ref.read(efSendAmountProvider.notifier).state = _localizedStringToNum( + sendText, + ); + } + if (flushReceive) { + ref.read(efReceiveAmountProvider.notifier).state = _localizedStringToNum( + receiveText, + ); + } + return flushSend || flushReceive; + } + Decimal? _localizedStringToNum(String? value) { if (value == null) { return null; } - return AmountUnit.normal - .tryParse( - value, - locale: ref.read(localeServiceChangeNotifierProvider).locale, - coin: Bitcoin( - CryptoCurrencyNetwork.main, - ), // dummy value (not used due to override) - overrideWithDecimalPlacesFromString: true, - ) - ?.decimal; + return Amount.tryParseEditableDecimal( + value, + locale: ref.read(localeServiceChangeNotifierProvider).locale, + ); + } + + void _relocalizePendingAmountText() { + final nextLocale = ref.read(localeServiceChangeNotifierProvider).locale; + if (nextLocale == _amountInputLocale) { + return; + } + + if (_pendingSendAmountText != null) { + _pendingSendAmountText = Amount.relocalizeEditableDecimal( + _pendingSendAmountText!, + sourceLocale: _amountInputLocale, + targetLocale: nextLocale, + ); + } + if (_pendingReceiveAmountText != null) { + _pendingReceiveAmountText = Amount.relocalizeEditableDecimal( + _pendingReceiveAmountText!, + sourceLocale: _amountInputLocale, + targetLocale: nextLocale, + ); + } + _amountInputLocale = nextLocale; } void selectSendCurrency() async { @@ -234,6 +298,7 @@ class _ExchangeFormState extends ConsumerState { } Future _swap() async { + _flushPendingAmountChange(); _swapLock = true; _sendFocusNode.unfocus(); _receiveFocusNode.unfocus(); @@ -392,6 +457,11 @@ class _ExchangeFormState extends ConsumerState { } void onExchangePressed() async { + if (_flushPendingAmountChange()) { + await showUpdatingExchangeRate(whileFuture: update()); + if (!mounted) return; + } + final exchangeName = ref.read(efExchangeProvider).name; final fromCurrency = ref @@ -421,8 +491,24 @@ class _ExchangeFormState extends ConsumerState { } final rateType = ref.read(efRateTypeProvider); - final estimate = ref.read(efEstimateProvider)!; - final sendAmount = ref.read(efSendAmountProvider)!; + final estimate = ref.read(efEstimateProvider); + final sendAmount = ref.read(efSendAmountProvider); + + if (estimate == null || sendAmount == null) { + if (mounted) { + await showDialog( + context: context, + builder: (context) => StackOkDialog( + title: "Exchange rate not ready", + message: + "Please wait for the exchange rate to update and try again", + maxWidth: Util.isDesktop ? 300 : null, + ), + ); + } + + return; + } if (rateType == ExchangeRateType.fixed && toCurrency.ticker.toUpperCase() == "WOW") { @@ -645,9 +731,30 @@ class _ExchangeFormState extends ConsumerState { Future update() async { final uuid = const Uuid().v1(); _latestUuid = uuid; - _addUpdate(uuid); - for (final exchange in usableExchanges) { - ref.read(efEstimatesListProvider(exchange.name).notifier).state = null; + + final exchanges = usableExchanges; + final estimatesNotifiers = { + for (final exchange in exchanges) + exchange.name: ref.read( + efEstimatesListProvider(exchange.name).notifier, + ), + }; + final refreshingNotifier = ref.read(efRefreshingProvider.notifier); + + _uuids.add(uuid); + refreshingNotifier.state = true; + + void removeUpdate() { + _uuids.remove(uuid); + if (_uuids.isEmpty) { + WidgetsBinding.instance.addPostFrameCallback((_) { + refreshingNotifier.state = false; + }); + } + } + + for (final exchange in exchanges) { + estimatesNotifiers[exchange.name]!.state = null; } final reversed = ref.read(efReversedProvider); @@ -660,14 +767,14 @@ class _ExchangeFormState extends ConsumerState { amount <= Decimal.zero || pair.send == null || pair.receive == null) { - _removeUpdate(uuid); + removeUpdate(); return; } final rateType = ref.read(efRateTypeProvider); final Map>, Range?>> results = {}; - for (final exchange in usableExchanges) { + for (final exchange in exchanges) { final sendCurrency = pair.send?.forExchange(exchange.name); final receiveCurrency = pair.receive?.forExchange(exchange.name); @@ -704,33 +811,18 @@ class _ExchangeFormState extends ConsumerState { } } - for (final exchange in usableExchanges) { + for (final exchange in exchanges) { if (uuid == _latestUuid) { - ref.read(efEstimatesListProvider(exchange.name).notifier).state = - results[exchange.name]; + estimatesNotifiers[exchange.name]!.state = results[exchange.name]; } } - _removeUpdate(uuid); + removeUpdate(); } String? _latestUuid; final Set _uuids = {}; - void _addUpdate(String uuid) { - _uuids.add(uuid); - ref.read(efRefreshingProvider.notifier).state = true; - } - - void _removeUpdate(String uuid) { - _uuids.remove(uuid); - if (_uuids.isEmpty) { - WidgetsBinding.instance.addPostFrameCallback((_) { - ref.read(efRefreshingProvider.notifier).state = false; - }); - } - } - void updateSend(Estimate? estimate) { ref.read(efSendAmountProvider.notifier).state = estimate?.estimatedAmount; } @@ -744,6 +836,7 @@ class _ExchangeFormState extends ConsumerState { void initState() { _sendController = TextEditingController(); _receiveController = TextEditingController(); + _amountInputLocale = ref.read(localeServiceChangeNotifierProvider).locale; walletId = widget.walletId; coin = widget.coin; @@ -809,6 +902,8 @@ class _ExchangeFormState extends ConsumerState { @override void dispose() { + _sendFieldOnChangedTimer?.cancel(); + _receiveFieldOnChangedTimer?.cancel(); _receiveController.dispose(); _sendController.dispose(); _receiveFocusNode.dispose(); @@ -824,6 +919,20 @@ class _ExchangeFormState extends ConsumerState { final isEstimated = rateType == ExchangeRateType.estimated; + // A pending debounce captured text in the old locale. Relocalize both the + // controllers and the stored user edits before parsing under the new + // locale, then refresh the quote for the newly committed amount. + listenForAmountRelocalization( + ref.listen, + controllers: [_sendController, _receiveController], + onRelocalized: () { + _relocalizePendingAmountText(); + if (_flushPendingAmountChange()) { + unawaited(update()); + } + }, + ); + ref.listen(efReceiveAmountStringProvider, (previous, String next) { if (!_receiveFocusNode.hasFocus) { _receiveController.text = isEstimated && next.isEmpty ? "-" : next; @@ -866,6 +975,10 @@ class _ExchangeFormState extends ConsumerState { }); ref.listen(efCurrencyPairProvider, (previous, next) { + // Commit pending user text before its debounce can apply after the pair + // has changed. The controller may already have been rewritten from the + // previous provider value while focus moved between the fields. + _flushPendingAmountChange(); if (!_swapLock) { update(); } diff --git a/lib/pages/exchange_view/exchange_step_views/step_2_view.dart b/lib/pages/exchange_view/exchange_step_views/step_2_view.dart index 1b2fa42c44..1b926dd89a 100644 --- a/lib/pages/exchange_view/exchange_step_views/step_2_view.dart +++ b/lib/pages/exchange_view/exchange_step_views/step_2_view.dart @@ -20,6 +20,7 @@ import '../../../utilities/address_utils.dart'; import '../../../utilities/barcode_scanner_interface.dart'; import '../../../utilities/clipboard_interface.dart'; import '../../../utilities/constants.dart'; +import '../../../utilities/extra_id_currency_support.dart'; import '../../../utilities/logger.dart'; import '../../../utilities/text_styles.dart'; import '../../../widgets/background.dart'; @@ -61,12 +62,42 @@ class _Step2ViewState extends ConsumerState { late final TextEditingController _toController; late final TextEditingController _refundController; + late final TextEditingController _toMemoController; + late final TextEditingController _refundMemoController; late final FocusNode _toFocusNode; late final FocusNode _refundFocusNode; + late final FocusNode _toMemoFocusNode; + late final FocusNode _refundMemoFocusNode; bool enableNext = false; + bool get _showRecipientMemo => + ref.read(efExchangeProvider).supportsExtraId && + ExtraIdCurrencySupport.mayRequire(model.receiveTicker); + + bool get _showRefundMemo => + ref.read(efExchangeProvider).supportsExtraId && + ExtraIdCurrencySupport.mayRequire(model.sendTicker); + + void _setRecipientMemo(String? memo) { + // A null memo means the selected address source did not provide one. In + // that case keep any memo the user already entered instead of wiping it. + if (memo == null) return; + final value = _showRecipientMemo ? memo : ""; + _toMemoController.text = value; + model.extraId = value.isEmpty ? null : value; + } + + void _setRefundMemo(String? memo) { + // A null memo means the selected address source did not provide one. In + // that case keep any memo the user already entered instead of wiping it. + if (memo == null) return; + final value = _showRefundMemo ? memo : ""; + _refundMemoController.text = value; + model.refundExtraId = value.isEmpty ? null : value; + } + void _onRefundQrTapped() async { try { final qrResult = await ref.read(pBarcodeScanner).scan(context: context); @@ -81,6 +112,7 @@ class _Step2ViewState extends ConsumerState { // auto fill address _refundController.text = paymentData.address; model.refundAddress = _refundController.text; + _setRefundMemo(paymentData.memo); setState(() { enableNext = @@ -135,6 +167,7 @@ class _Step2ViewState extends ConsumerState { // auto fill address _toController.text = paymentData.address; model.recipientAddress = _toController.text; + _setRecipientMemo(paymentData.memo); setState(() { enableNext = @@ -150,7 +183,7 @@ class _Step2ViewState extends ConsumerState { enableNext = _toController.text.isNotEmpty && (_refundController.text.isNotEmpty || - !!ref.read(efExchangeProvider).supportsRefundAddress); + !ref.read(efExchangeProvider).supportsRefundAddress); }); } } on PlatformException catch (e, s) { @@ -184,9 +217,15 @@ class _Step2ViewState extends ConsumerState { _toController = TextEditingController(); _refundController = TextEditingController(); + _toMemoController = TextEditingController(text: model.extraId ?? ""); + _refundMemoController = TextEditingController( + text: model.refundExtraId ?? "", + ); _toFocusNode = FocusNode(); _refundFocusNode = FocusNode(); + _toMemoFocusNode = FocusNode(); + _refundMemoFocusNode = FocusNode(); final tuple = ref.read(exchangeSendFromWalletIdStateProvider.state).state; if (tuple != null) { @@ -222,9 +261,13 @@ class _Step2ViewState extends ConsumerState { void dispose() { _toController.dispose(); _refundController.dispose(); + _toMemoController.dispose(); + _refundMemoController.dispose(); _toFocusNode.dispose(); _refundFocusNode.dispose(); + _toMemoFocusNode.dispose(); + _refundMemoFocusNode.dispose(); super.dispose(); } @@ -436,8 +479,24 @@ class _Step2ViewState extends ConsumerState { .text! .trim(); - _toController.text = - content; + final paymentData = + AddressUtils.parsePaymentUri( + content, + logging: Logging + .instance, + ); + if (paymentData != + null) { + _toController.text = + paymentData + .address; + _setRecipientMemo( + paymentData.memo, + ); + } else { + _toController.text = + content; + } model.recipientAddress = _toController .text; @@ -543,6 +602,39 @@ class _Step2ViewState extends ConsumerState { style: STextStyles.label(context), ), ), + if (_showRecipientMemo) const SizedBox(height: 16), + if (_showRecipientMemo) + Text( + "Memo or destination tag", + style: STextStyles.smallMed12(context), + ), + if (_showRecipientMemo) const SizedBox(height: 4), + if (_showRecipientMemo) + ClipRRect( + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + child: TextField( + key: const Key( + "recipientExchangeStep2ViewMemoFieldKey", + ), + controller: _toMemoController, + focusNode: _toMemoFocusNode, + autocorrect: false, + enableSuggestions: false, + style: STextStyles.field(context), + onChanged: (value) { + model.extraId = value.isEmpty + ? null + : value; + }, + decoration: standardInputDecoration( + "Enter memo or tag if required", + _toMemoFocusNode, + context, + ), + ), + ), const SizedBox(height: 24), if (supportsRefund) Row( @@ -708,9 +800,27 @@ class _Step2ViewState extends ConsumerState { .text! .trim(); - _refundController - .text = - content; + final paymentData = + AddressUtils.parsePaymentUri( + content, + logging: Logging + .instance, + ); + if (paymentData != + null) { + _refundController + .text = + paymentData + .address; + _setRefundMemo( + paymentData + .memo, + ); + } else { + _refundController + .text = + content; + } model.refundAddress = _refundController .text; @@ -815,6 +925,41 @@ class _Step2ViewState extends ConsumerState { style: STextStyles.label(context), ), ), + if (supportsRefund && _showRefundMemo) + const SizedBox(height: 16), + if (supportsRefund && _showRefundMemo) + Text( + "Refund memo or destination tag", + style: STextStyles.smallMed12(context), + ), + if (supportsRefund && _showRefundMemo) + const SizedBox(height: 4), + if (supportsRefund && _showRefundMemo) + ClipRRect( + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + child: TextField( + key: const Key( + "refundExchangeStep2ViewMemoFieldKey", + ), + controller: _refundMemoController, + focusNode: _refundMemoFocusNode, + autocorrect: false, + enableSuggestions: false, + style: STextStyles.field(context), + onChanged: (value) { + model.refundExtraId = value.isEmpty + ? null + : value; + }, + decoration: standardInputDecoration( + "Enter memo or tag if required", + _refundMemoFocusNode, + context, + ), + ), + ), const SizedBox(height: 16), const Spacer(), Row( diff --git a/lib/pages/exchange_view/exchange_step_views/step_3_view.dart b/lib/pages/exchange_view/exchange_step_views/step_3_view.dart index 4f0b352c3c..b649d56121 100644 --- a/lib/pages/exchange_view/exchange_step_views/step_3_view.dart +++ b/lib/pages/exchange_view/exchange_step_views/step_3_view.dart @@ -169,6 +169,27 @@ class _Step3ViewState extends ConsumerState { ], ), ), + if (model.extraId?.isNotEmpty == true) + const SizedBox(height: 8), + if (model.extraId?.isNotEmpty == true) + RoundedWhiteContainer( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Recipient memo or tag", + style: STextStyles.itemSubtitle(context), + ), + const SizedBox(height: 4), + Text( + model.extraId!, + style: STextStyles.itemSubtitle12( + context, + ), + ), + ], + ), + ), if (supportsRefund) const SizedBox(height: 8), if (supportsRefund) RoundedWhiteContainer( @@ -189,6 +210,29 @@ class _Step3ViewState extends ConsumerState { ], ), ), + if (supportsRefund && + model.refundExtraId?.isNotEmpty == true) + const SizedBox(height: 8), + if (supportsRefund && + model.refundExtraId?.isNotEmpty == true) + RoundedWhiteContainer( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Refund memo or tag", + style: STextStyles.itemSubtitle(context), + ), + const SizedBox(height: 4), + Text( + model.refundExtraId!, + style: STextStyles.itemSubtitle12( + context, + ), + ), + ], + ), + ), const SizedBox(height: 8), const Spacer(), Row( @@ -205,14 +249,12 @@ class _Step3ViewState extends ConsumerState { ), child: Text( "Back", - style: STextStyles.button( - context, - ).copyWith( - color: - Theme.of(context) + style: STextStyles.button(context) + .copyWith( + color: Theme.of(context) .extension()! .buttonTextSecondary, - ), + ), ), ), ), @@ -224,22 +266,19 @@ class _Step3ViewState extends ConsumerState { showDialog( context: context, barrierDismissible: false, - builder: - (_) => WillPopScope( - onWillPop: () async => false, - child: Container( - color: Theme.of(context) - .extension()! - .overlay - .withOpacity(0.6), - child: - const CustomLoadingOverlay( - message: - "Creating a trade", - eventBus: null, - ), - ), + builder: (_) => WillPopScope( + onWillPop: () async => false, + child: Container( + color: Theme.of(context) + .extension()! + .overlay + .withOpacity(0.6), + child: const CustomLoadingOverlay( + message: "Creating a trade", + eventBus: null, ), + ), + ), ), ); @@ -256,17 +295,16 @@ class _Step3ViewState extends ConsumerState { fixedRate: model.rateType != ExchangeRateType.estimated, - amount: - model.reversed - ? model.receiveAmount - : model.sendAmount, + amount: model.reversed + ? model.receiveAmount + : model.sendAmount, addressTo: model.recipientAddress!, - extraId: null, - addressRefund: - supportsRefund - ? model.refundAddress! - : "", - refundExtraId: "", + extraId: model.extraId, + addressRefund: supportsRefund + ? model.refundAddress! + : "", + refundExtraId: + model.refundExtraId ?? "", estimate: model.estimate, reversed: model.reversed, ); @@ -278,8 +316,8 @@ class _Step3ViewState extends ConsumerState { // TODO: better errors String? message; if (response.exception != null) { - message = - response.exception!.toString(); + message = response.exception! + .toString(); if (message.startsWith( "FormatException:", ) && @@ -293,12 +331,10 @@ class _Step3ViewState extends ConsumerState { showDialog( context: context, barrierDismissible: true, - builder: - (_) => StackDialog( - title: - "Failed to create trade", - message: message ?? "", - ), + builder: (_) => StackDialog( + title: "Failed to create trade", + message: message ?? "", + ), ), ); } diff --git a/lib/pages/exchange_view/exchange_step_views/step_4_view.dart b/lib/pages/exchange_view/exchange_step_views/step_4_view.dart index a48b85b23c..aee16aa136 100644 --- a/lib/pages/exchange_view/exchange_step_views/step_4_view.dart +++ b/lib/pages/exchange_view/exchange_step_views/step_4_view.dart @@ -250,7 +250,23 @@ class _Step4ViewState extends ConsumerState { final wallet = ref.read(pWallets).getWallet(tuple.item1); - final Amount amount = model.sendAmount.toAmount( + final payInDecimal = model.payInDecimal; + if (payInDecimal == null) { + if (mounted) { + await showDialog( + context: context, + barrierDismissible: true, + builder: (context) => StackOkDialog( + title: "Invalid trade amount", + message: + "The exchange returned an invalid pay-in amount:" + " \"${model.payInAmount}\"", + ), + ); + } + return; + } + final Amount amount = payInDecimal.toAmount( fractionDigits: wallet.info.coin.fractionDigits, ); final address = model.trade!.payInAddress; @@ -456,10 +472,10 @@ class _Step4ViewState extends ConsumerState { DetailItem( title: "Amount", detail: - "${model.sendAmount.toString()} " + "${model.payInAmount} " "${model.sendTicker.toUpperCase()}", button: SimpleCopyButton( - data: model.sendAmount.toString(), + data: model.payInAmount, ), ), const SizedBox(height: 8), @@ -554,7 +570,7 @@ class _WarningInfo extends StatelessWidget { text: TextSpan( text: "You must send at least " - "${model.sendAmount.toString()} ${model.sendTicker}. ", + "${model.payInAmount} ${model.sendTicker}. ", style: STextStyles.label700(context).copyWith( color: Theme.of( context, @@ -564,7 +580,7 @@ class _WarningInfo extends StatelessWidget { TextSpan( text: "If you send less than " - "${model.sendAmount.toString()} ${model.sendTicker}," + "${model.payInAmount} ${model.sendTicker}," " your transaction may not be converted and it may not be" " refunded.", style: STextStyles.label(context).copyWith( @@ -609,6 +625,20 @@ class _SendFromButton extends ConsumerWidget { tuple.item2.ticker.toLowerCase()) { await confirmSend(tuple); } else { + final payInDecimal = model.payInDecimal; + if (payInDecimal == null) { + await showDialog( + context: context, + barrierDismissible: true, + builder: (context) => StackOkDialog( + title: "Invalid trade amount", + message: + "The exchange returned an invalid pay-in amount:" + " \"${model.payInAmount}\"", + ), + ); + return; + } await Navigator.of(context).push( RouteGenerator.getRoute( shouldUseMaterialRoute: RouteGenerator.useMaterialPageRoute, @@ -621,7 +651,7 @@ class _SendFromButton extends ConsumerWidget { return SendFromView( coin: coin, - amount: model.sendAmount.toAmount( + amount: payInDecimal.toAmount( fractionDigits: coin.fractionDigits, ), address: model.trade!.payInAddress, diff --git a/lib/pages/exchange_view/sub_widgets/sorted_exchange_providers.dart b/lib/pages/exchange_view/sub_widgets/sorted_exchange_providers.dart index c478f9ead0..b0a99c7abc 100644 --- a/lib/pages/exchange_view/sub_widgets/sorted_exchange_providers.dart +++ b/lib/pages/exchange_view/sub_widgets/sorted_exchange_providers.dart @@ -58,17 +58,16 @@ class _SortedExchangeProvidersState } flattened.sort((a, b) { - if (a.$2 == null && b.$2 == null) return 1; - if (a.$2 != null && b.$2 == null) return 0; - if (a.$2 == null && b.$2 != null) return 0; + if (a.$2 != null && b.$2 != null) { + assert(a.$2!.reversed == b.$2!.reversed); + } - // or we get problems!!! - assert(a.$2!.reversed == b.$2!.reversed); + final aRate = a.$2 == null ? null : _getRate(a.$2!, amount, rcvTicker); + final bRate = b.$2 == null ? null : _getRate(b.$2!, amount, rcvTicker); - return _getRate(a.$2!, amount, rcvTicker) > - _getRate(b.$2!, amount, rcvTicker) - ? 0 - : 1; + if (aRate == null) return bRate == null ? 0 : 1; + if (bRate == null) return -1; + return bRate.decimal.compareTo(aRate.decimal); }); return flattened; diff --git a/lib/pages/masternodes/sub_widgets/register_masternode_form.dart b/lib/pages/masternodes/sub_widgets/register_masternode_form.dart index 6bd79d17a2..18b4ac5ee6 100644 --- a/lib/pages/masternodes/sub_widgets/register_masternode_form.dart +++ b/lib/pages/masternodes/sub_widgets/register_masternode_form.dart @@ -1,7 +1,12 @@ +import 'package:decimal/decimal.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../providers/global/locale_provider.dart'; import '../../../providers/global/wallets_provider.dart'; +import '../../../utilities/amount/amount.dart'; +import '../../../utilities/amount/amount_field_relocalization.dart'; +import '../../../utilities/amount/amount_input_formatter.dart'; import '../../../themes/stack_colors.dart'; import '../../../utilities/if_not_already.dart'; import '../../../utilities/logger.dart'; @@ -60,9 +65,17 @@ class _RegisterMasternodeFormState bool _enableCreateButton = false; + // Parse as a 2-decimal Amount so pasted overprecision ("0.001") is + // rejected instead of silently rounding to zero basis points. + Decimal? get _operatorRewardPercent => Amount.tryParseEditableAmount( + _operatorRewardController.text, + locale: ref.read(localeServiceChangeNotifierProvider).locale, + fractionDigits: 2, + )?.decimal; + void _validate() { if (mounted) { - final percent = double.tryParse(_operatorRewardController.text); + final percent = _operatorRewardPercent; setState(() { _enableCreateButton = [ _ipAndPortController.text @@ -72,8 +85,7 @@ class _RegisterMasternodeFormState .length == 2, _operatorPubKeyController.text.trim().isNotEmpty, - percent != null && !percent.isNegative, - percent != null && percent <= 100.0, + percent != null && percent <= Decimal.fromInt(100), _payoutAddressController.text.trim().isNotEmpty, ].every((e) => e); }); @@ -90,11 +102,15 @@ class _RegisterMasternodeFormState // according to https://github.com/cypherstack/stack_wallet/blob/c898a70f808ed5490b8dd23571f5f162d9e38158/lib/wallets/wallet/impl/firo_wallet.dart#L1064 // this should be a percent of 10000 - final operatorPercent = double.parse(_operatorRewardController.text); - final operatorReward = (10000 * (operatorPercent / 100)).round().clamp( - 0, - 10000, - ); + final operatorPercent = _operatorRewardPercent; + if (operatorPercent == null) { + throw Exception("Invalid operator reward"); + } + final operatorReward = (operatorPercent * Decimal.fromInt(100)) + .round() + .toBigInt() + .toInt() + .clamp(0, 10000); final wallet = ref.read(pWallets).getWallet(widget.firoWalletId) as FiroWallet; @@ -170,6 +186,12 @@ class _RegisterMasternodeFormState Widget build(BuildContext context) { final stack = Theme.of(context).extension()!; + listenForAmountRelocalization( + ref.listen, + controllers: [_operatorRewardController], + onRelocalized: _validate, + ); + return Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, @@ -254,6 +276,18 @@ class _RegisterMasternodeFormState controller: _operatorRewardController, showPasteClearButton: true, maxLines: 1, + keyboardType: const TextInputType.numberWithOptions(decimal: true), + inputFormatters: [ + AmountInputFormatter( + controller: _operatorRewardController, + decimals: 2, + locale: ref.watch( + localeServiceChangeNotifierProvider.select( + (value) => value.locale, + ), + ), + ), + ], onChangedComprehensive: (_) => _validate(), ), SizedBox(height: Util.isDesktop ? 24 : 16), diff --git a/lib/pages/paynym/paynym_home_view.dart b/lib/pages/paynym/paynym_home_view.dart index d27a8ae429..d70cff6cf0 100644 --- a/lib/pages/paynym/paynym_home_view.dart +++ b/lib/pages/paynym/paynym_home_view.dart @@ -45,10 +45,7 @@ import 'subwidgets/paynym_followers_list.dart'; import 'subwidgets/paynym_following_list.dart'; class PaynymHomeView extends ConsumerStatefulWidget { - const PaynymHomeView({ - super.key, - required this.walletId, - }); + const PaynymHomeView({super.key, required this.walletId}); final String walletId; @@ -86,23 +83,20 @@ class _PaynymHomeViewState extends ConsumerState { leading: Row( children: [ Padding( - padding: const EdgeInsets.only( - left: 24, - right: 20, - ), + padding: const EdgeInsets.only(left: 24, right: 20), child: AppBarIconButton( size: 32, - color: Theme.of(context) - .extension()! - .textFieldDefaultBG, + color: Theme.of( + context, + ).extension()!.textFieldDefaultBG, shadows: const [], icon: SvgPicture.asset( Assets.svg.arrowLeft, width: 18, height: 18, - color: Theme.of(context) - .extension()! - .topNavIconPrimary, + color: Theme.of( + context, + ).extension()!.topNavIconPrimary, ), onPressed: Navigator.of(context).pop, ), @@ -113,13 +107,8 @@ class _PaynymHomeViewState extends ConsumerState { height: 32, color: Theme.of(context).extension()!.textDark, ), - const SizedBox( - width: 10, - ), - Text( - "PayNym", - style: STextStyles.desktopH3(context), - ), + const SizedBox(width: 10), + Text("PayNym", style: STextStyles.desktopH3(context)), ], ), trailing: kDisableFollowing @@ -146,12 +135,13 @@ class _PaynymHomeViewState extends ConsumerState { ); }, child: RoundedContainer( - padding: - const EdgeInsets.symmetric(horizontal: 24.0), + padding: const EdgeInsets.symmetric( + horizontal: 24.0, + ), color: _followButtonHoverState - ? Theme.of(context) - .extension()! - .highlight + ? Theme.of( + context, + ).extension()!.highlight : Colors.transparent, radiusMultiplier: 100, child: Row( @@ -160,28 +150,22 @@ class _PaynymHomeViewState extends ConsumerState { Assets.svg.plus, width: 16, height: 16, - color: Theme.of(context) - .extension()! - .textDark, - ), - const SizedBox( - width: 8, + color: Theme.of( + context, + ).extension()!.textDark, ), + const SizedBox(width: 8), Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Text( "Follow", - style: STextStyles - .desktopButtonSecondaryEnabled( - context, - ).copyWith( - fontSize: 16, - ), - ), - const SizedBox( - height: 2, + style: + STextStyles.desktopButtonSecondaryEnabled( + context, + ).copyWith(fontSize: 16), ), + const SizedBox(height: 2), ], ), ], @@ -215,9 +199,9 @@ class _PaynymHomeViewState extends ConsumerState { Assets.svg.circlePlusFilled, width: 20, height: 20, - color: Theme.of(context) - .extension()! - .accentColorDark, + color: Theme.of( + context, + ).extension()!.accentColorDark, ), onPressed: () { Navigator.of(context).pushNamed( @@ -237,9 +221,9 @@ class _PaynymHomeViewState extends ConsumerState { Assets.svg.circleQuestion, width: 20, height: 20, - color: Theme.of(context) - .extension()! - .accentColorDark, + color: Theme.of( + context, + ).extension()!.accentColorDark, ), onPressed: () { // todo info ? @@ -247,22 +231,18 @@ class _PaynymHomeViewState extends ConsumerState { ), ), ), - const SizedBox( - width: 4, - ), + const SizedBox(width: 4), ], ), body: ConditionalParent( condition: !isDesktop, builder: (child) => SafeArea( - child: Padding( - padding: const EdgeInsets.all(16), - child: child, - ), + child: Padding(padding: const EdgeInsets.all(16), child: child), ), child: Column( - crossAxisAlignment: - isDesktop ? CrossAxisAlignment.start : CrossAxisAlignment.center, + crossAxisAlignment: isDesktop + ? CrossAxisAlignment.start + : CrossAxisAlignment.center, children: [ if (!isDesktop) Column( @@ -281,13 +261,10 @@ class _PaynymHomeViewState extends ConsumerState { secretCount = 0; } - timer ??= Timer( - const Duration(milliseconds: 1500), - () { - secretCount = 0; - timer = null; - }, - ); + timer ??= Timer(const Duration(milliseconds: 1500), () { + secretCount = 0; + timer = null; + }); }, child: PayNymBot( paymentCodeString: ref @@ -297,9 +274,7 @@ class _PaynymHomeViewState extends ConsumerState { .code, ), ), - const SizedBox( - height: 10, - ), + const SizedBox(height: 10), Text( ref .watch(myPaynymAccountStateProvider.state) @@ -307,9 +282,7 @@ class _PaynymHomeViewState extends ConsumerState { .nymName, style: STextStyles.desktopMenuItemSelected(context), ), - const SizedBox( - height: 4, - ), + const SizedBox(height: 4), Text( Format.shorten( ref @@ -320,13 +293,9 @@ class _PaynymHomeViewState extends ConsumerState { 12, 5, ), - style: STextStyles.label(context).copyWith( - fontSize: 14, - ), - ), - const SizedBox( - height: 11, + style: STextStyles.label(context).copyWith(fontSize: 14), ), + const SizedBox(height: 11), Row( children: [ Expanded( @@ -337,9 +306,9 @@ class _PaynymHomeViewState extends ConsumerState { icon: CopyIcon( width: 12, height: 12, - color: Theme.of(context) - .extension()! - .buttonTextSecondary, + color: Theme.of( + context, + ).extension()!.buttonTextSecondary, ), onPressed: () async { await Clipboard.setData( @@ -362,9 +331,7 @@ class _PaynymHomeViewState extends ConsumerState { }, ), ), - const SizedBox( - width: 13, - ), + const SizedBox(width: 13), Expanded( child: SecondaryButton( label: "Share", @@ -373,9 +340,9 @@ class _PaynymHomeViewState extends ConsumerState { icon: ShareIcon( width: 12, height: 12, - color: Theme.of(context) - .extension()! - .buttonTextSecondary, + color: Theme.of( + context, + ).extension()!.buttonTextSecondary, ), onPressed: () async { Rect? sharePositionOrigin; @@ -388,20 +355,19 @@ class _PaynymHomeViewState extends ConsumerState { } } - await Share.share( - ref - .read(myPaynymAccountStateProvider.state) - .state! - .nonSegwitPaymentCode - .code, - sharePositionOrigin: sharePositionOrigin, + await SharePlus.instance.share( + ShareParams( + text: ref + .read(myPaynymAccountStateProvider)! + .nonSegwitPaymentCode + .code, + sharePositionOrigin: sharePositionOrigin, + ), ); }, ), ), - const SizedBox( - width: 13, - ), + const SizedBox(width: 13), Expanded( child: SecondaryButton( label: "Address", @@ -410,9 +376,9 @@ class _PaynymHomeViewState extends ConsumerState { icon: QrCodeIcon( width: 12, height: 12, - color: Theme.of(context) - .extension()! - .buttonTextSecondary, + color: Theme.of( + context, + ).extension()!.buttonTextSecondary, ), onPressed: () { showDialog( @@ -437,9 +403,7 @@ class _PaynymHomeViewState extends ConsumerState { padding: const EdgeInsets.all(16), child: Row( children: [ - const SizedBox( - width: 4, - ), + const SizedBox(width: 4), GestureDetector( onTap: () { secretCount++; @@ -469,9 +433,7 @@ class _PaynymHomeViewState extends ConsumerState { .code, ), ), - const SizedBox( - width: 16, - ), + const SizedBox(width: 16), Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -482,9 +444,7 @@ class _PaynymHomeViewState extends ConsumerState { .nymName, style: STextStyles.desktopH3(context), ), - const SizedBox( - height: 4, - ), + const SizedBox(height: 4), Text( Format.shorten( ref @@ -495,8 +455,9 @@ class _PaynymHomeViewState extends ConsumerState { 12, 5, ), - style: - STextStyles.desktopTextExtraExtraSmall(context), + style: STextStyles.desktopTextExtraExtraSmall( + context, + ), ), ], ), @@ -508,9 +469,9 @@ class _PaynymHomeViewState extends ConsumerState { icon: CopyIcon( width: 18, height: 18, - color: Theme.of(context) - .extension()! - .textDark, + color: Theme.of( + context, + ).extension()!.textDark, ), onPressed: () async { await Clipboard.setData( @@ -532,9 +493,7 @@ class _PaynymHomeViewState extends ConsumerState { ); }, ), - const SizedBox( - width: 16, - ), + const SizedBox(width: 16), SecondaryButton( label: "Address", width: 160, @@ -542,9 +501,9 @@ class _PaynymHomeViewState extends ConsumerState { icon: QrCodeIcon( width: 18, height: 18, - color: Theme.of(context) - .extension()! - .textDark, + color: Theme.of( + context, + ).extension()!.textDark, ), onPressed: () { showDialog( @@ -561,10 +520,7 @@ class _PaynymHomeViewState extends ConsumerState { ), ), ), - if (!isDesktop) - const SizedBox( - height: 24, - ), + if (!isDesktop) const SizedBox(height: 24), ConditionalParent( condition: isDesktop, builder: (child) => Padding( @@ -579,9 +535,9 @@ class _PaynymHomeViewState extends ConsumerState { onColor: Theme.of(context).extension()!.popupBG, onText: "Following (${ref.watch(myPaynymAccountStateProvider.state).state?.following.length ?? 0})", - offColor: Theme.of(context) - .extension()! - .textFieldDefaultBG, + offColor: Theme.of( + context, + ).extension()!.textFieldDefaultBG, offText: "Followers (${ref.watch(myPaynymAccountStateProvider.state).state?.followers.length ?? 0})", isOn: showFollowers, @@ -599,9 +555,7 @@ class _PaynymHomeViewState extends ConsumerState { ), ), ), - SizedBox( - height: isDesktop ? 20 : 16, - ), + SizedBox(height: isDesktop ? 20 : 16), Expanded( child: ConditionalParent( condition: isDesktop, @@ -610,13 +564,8 @@ class _PaynymHomeViewState extends ConsumerState { child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - SizedBox( - width: 490, - child: child, - ), - const SizedBox( - width: 24, - ), + SizedBox(width: 490, child: child), + const SizedBox(width: 24), if (ref .watch(selectedPaynymDetailsItemProvider.state) .state != @@ -645,24 +594,16 @@ class _PaynymHomeViewState extends ConsumerState { .watch(selectedPaynymDetailsItemProvider.state) .state != null) - const SizedBox( - width: 24, - ), + const SizedBox(width: 24), ], ), ), child: ConditionalParent( condition: !isDesktop, - builder: (child) => Container( - child: child, - ), + builder: (child) => Container(child: child), child: !showFollowers - ? PaynymFollowingList( - walletId: widget.walletId, - ) - : PaynymFollowersList( - walletId: widget.walletId, - ), + ? PaynymFollowingList(walletId: widget.walletId) + : PaynymFollowersList(walletId: widget.walletId), ), ), ), diff --git a/lib/pages/receive_view/addresses/address_card.dart b/lib/pages/receive_view/addresses/address_card.dart index f8b6ec8067..0cd361fb2a 100644 --- a/lib/pages/receive_view/addresses/address_card.dart +++ b/lib/pages/receive_view/addresses/address_card.dart @@ -132,9 +132,12 @@ class _AddressCardState extends ConsumerState { final file = await File("${tempDir.path}/qrcode.png").create(); await file.writeAsBytes(pngBytes); - await Share.shareFiles([ - "${tempDir.path}/qrcode.png", - ], text: "Receive URI QR Code"); + await SharePlus.instance.share( + ShareParams( + files: [XFile("${tempDir.path}/qrcode.png")], + text: "Receive URI QR Code", + ), + ); } } catch (e) { //todo: comeback to this diff --git a/lib/pages/receive_view/addresses/address_qr_popup.dart b/lib/pages/receive_view/addresses/address_qr_popup.dart index b4446f5431..6af226f274 100644 --- a/lib/pages/receive_view/addresses/address_qr_popup.dart +++ b/lib/pages/receive_view/addresses/address_qr_popup.dart @@ -58,8 +58,9 @@ class _AddressQrPopupState extends State { final RenderRepaintBoundary boundary = _qrKey.currentContext?.findRenderObject() as RenderRepaintBoundary; final ui.Image image = await boundary.toImage(); - final ByteData? byteData = - await image.toByteData(format: ui.ImageByteFormat.png); + final ByteData? byteData = await image.toByteData( + format: ui.ImageByteFormat.png, + ); final Uint8List pngBytes = byteData!.buffer.asUint8List(); if (shouldSaveInsteadOfShare) { @@ -67,7 +68,8 @@ class _AddressQrPopupState extends State { final dir = Directory("${Platform.environment['HOME']}"); if (!dir.existsSync()) { throw Exception( - "Home dir not found while trying to open filepicker on QR image save", + "Home dir not found while trying to open filepicker on QR image" + " save", ); } final path = await FilePicker.platform.saveFile( @@ -107,9 +109,11 @@ class _AddressQrPopupState extends State { final file = await File("${tempDir.path}/qrcode.png").create(); await file.writeAsBytes(pngBytes); - await Share.shareFiles( - ["${tempDir.path}/qrcode.png"], - text: "Receive URI QR Code", + await SharePlus.instance.share( + ShareParams( + files: [XFile("${tempDir.path}/qrcode.png")], + text: "Receive URI QR Code", + ), ); } } catch (e) { @@ -123,20 +127,10 @@ class _AddressQrPopupState extends State { return StackDialogBase( child: Column( children: [ - Text( - "todo: custom label", - style: STextStyles.pageTitleH2(context), - ), - const SizedBox( - height: 8, - ), - Text( - widget.addressString, - style: STextStyles.itemSubtitle(context), - ), - const SizedBox( - height: 16, - ), + Text("Address", style: STextStyles.pageTitleH2(context)), + const SizedBox(height: 8), + Text(widget.addressString, style: STextStyles.itemSubtitle(context)), + const SizedBox(height: 16), Center( child: RepaintBoundary( key: _qrKey, @@ -150,9 +144,7 @@ class _AddressQrPopupState extends State { ), ), ), - const SizedBox( - height: 16, - ), + const SizedBox(height: 16), Row( children: [ Expanded( @@ -167,15 +159,13 @@ class _AddressQrPopupState extends State { Assets.svg.share, width: 20, height: 20, - color: Theme.of(context) - .extension()! - .buttonTextSecondary, + color: Theme.of( + context, + ).extension()!.buttonTextSecondary, ), ), ), - const SizedBox( - width: 16, - ), + const SizedBox(width: 16), Expanded( child: PrimaryButton( width: 170, @@ -187,9 +177,9 @@ class _AddressQrPopupState extends State { Assets.svg.arrowDown, width: 20, height: 20, - color: Theme.of(context) - .extension()! - .buttonTextPrimary, + color: Theme.of( + context, + ).extension()!.buttonTextPrimary, ), ), ), diff --git a/lib/pages/receive_view/generate_receiving_uri_qr_code_view.dart b/lib/pages/receive_view/generate_receiving_uri_qr_code_view.dart index d485cfa148..0c5e3d7b17 100644 --- a/lib/pages/receive_view/generate_receiving_uri_qr_code_view.dart +++ b/lib/pages/receive_view/generate_receiving_uri_qr_code_view.dart @@ -14,17 +14,21 @@ import 'dart:typed_data'; import 'dart:ui' as ui; // import 'package:document_file_save_plus/document_file_save_plus.dart'; -import 'package:decimal/decimal.dart'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/svg.dart'; import 'package:path_provider/path_provider.dart'; import 'package:share_plus/share_plus.dart'; import '../../notifications/show_flush_bar.dart'; +import '../../providers/global/locale_provider.dart'; import '../../themes/stack_colors.dart'; import '../../utilities/address_utils.dart'; +import '../../utilities/amount/amount.dart'; +import '../../utilities/amount/amount_field_relocalization.dart'; +import '../../utilities/amount/amount_input_formatter.dart'; import '../../utilities/assets.dart'; import '../../utilities/clipboard_interface.dart'; import '../../utilities/constants.dart'; @@ -44,7 +48,7 @@ import '../../widgets/stack_dialog.dart'; import '../../widgets/stack_text_field.dart'; import '../../widgets/textfield_icon_button.dart'; -class GenerateUriQrCodeView extends StatefulWidget { +class GenerateUriQrCodeView extends ConsumerStatefulWidget { const GenerateUriQrCodeView({ super.key, required this.coin, @@ -59,10 +63,11 @@ class GenerateUriQrCodeView extends StatefulWidget { final ClipboardInterface clipboard; @override - State createState() => _GenerateUriQrCodeViewState(); + ConsumerState createState() => + _GenerateUriQrCodeViewState(); } -class _GenerateUriQrCodeViewState extends State { +class _GenerateUriQrCodeViewState extends ConsumerState { final _qrKey = GlobalKey(); late TextEditingController amountController; @@ -90,7 +95,8 @@ class _GenerateUriQrCodeViewState extends State { final dir = Directory("${Platform.environment['HOME']}"); if (!dir.existsSync()) { throw Exception( - "Home dir not found while trying to open filepicker on QR image save", + "Home dir not found while trying to open filepicker on QR image" + " save", ); } final path = await FilePicker.platform.saveFile( @@ -122,19 +128,25 @@ class _GenerateUriQrCodeViewState extends State { } } } else { - // await DocumentFileSavePlus.saveFile( - // pngBytes, - // "receive_qr_code_${DateTime.now().toLocal().toIso8601String()}.png", - // "image/png"); + // await DocumentFileSavePlus.saveFile( + // pngBytes, + // "receive_qr_code_" + // "${DateTime.now().toLocal().toIso8601String()}" + // ".png", + // "image/png", + // ); } } else { final tempDir = await getTemporaryDirectory(); final file = await File("${tempDir.path}/qrcode.png").create(); await file.writeAsBytes(pngBytes); - await Share.shareFiles([ - "${tempDir.path}/qrcode.png", - ], text: "Receive URI QR Code"); + await SharePlus.instance.share( + ShareParams( + files: [XFile("${tempDir.path}/qrcode.png")], + text: "Receive URI QR Code", + ), + ); } } catch (e) { //todo: comeback to this @@ -146,16 +158,12 @@ class _GenerateUriQrCodeViewState extends State { final amountString = amountController.text; final noteString = noteController.text; - // try "." - Decimal? amount = Decimal.tryParse(amountString); - if (amount == null) { - // try single instance of "," - final first = amountString.indexOf(","); - final last = amountString.lastIndexOf(","); - if (first == last) { - amount = Decimal.tryParse(amountString.replaceFirst(",", ".")); - } - } + final locale = ref.read(localeServiceChangeNotifierProvider).locale; + final amount = Amount.tryParseEditableAmount( + amountString, + locale: locale, + fractionDigits: widget.coin.fractionDigits, + ); if (amountString.isNotEmpty && amount == null) { showFloatingFlushBar( @@ -166,15 +174,6 @@ class _GenerateUriQrCodeViewState extends State { return null; } - final Map queryParams = {}; - - if (amountString.isNotEmpty) { - queryParams["amount"] = amount.toString(); - } - if (noteString.isNotEmpty) { - queryParams["message"] = noteString; - } - String receivingAddress = widget.receivingAddress; if ((widget.coin is Bitcoincash || widget.coin is Ecash) && receivingAddress.contains(":")) { @@ -182,10 +181,11 @@ class _GenerateUriQrCodeViewState extends State { receivingAddress = receivingAddress.split(":").sublist(1).join(); } - final uriString = AddressUtils.buildUriString( - widget.coin.uriScheme, - receivingAddress, - queryParams, + final uriString = AddressUtils.buildPaymentUriString( + scheme: widget.coin.uriScheme, + address: receivingAddress, + amount: amount?.decimal.toString(), + message: noteString, ); Logging.instance.d("Generated receiving QR code for: $uriString"); @@ -237,10 +237,12 @@ class _GenerateUriQrCodeViewState extends State { Assets.svg.share, width: 14, height: 14, - color: - Theme.of( - context, - ).extension()!.buttonTextSecondary, + colorFilter: .mode( + Theme.of( + context, + ).extension()!.buttonTextSecondary, + .srcIn, + ), ), onPressed: () async { await _capturePng(false); @@ -291,70 +293,60 @@ class _GenerateUriQrCodeViewState extends State { Widget build(BuildContext context) { debugPrint("BUILD: $runtimeType"); + listenForAmountRelocalization(ref.listen, controllers: [amountController]); + return ConditionalParent( condition: !isDesktop, - builder: - (child) => Background( - child: Scaffold( - backgroundColor: - Theme.of(context).extension()!.background, - appBar: AppBar( - leading: AppBarBackButton( - onPressed: () async { - if (FocusScope.of(context).hasFocus) { - FocusScope.of(context).unfocus(); - await Future.delayed( - const Duration(milliseconds: 70), - ); - } - if (context.mounted) { - Navigator.of(context).pop(); - } - }, - ), - title: Text( - "Generate QR code", - style: STextStyles.navBarTitle(context), - ), - ), - body: SafeArea( - child: LayoutBuilder( - builder: (buildContext, constraints) { - return Padding( - padding: const EdgeInsets.only( - left: 12, - top: 12, - right: 12, + builder: (child) => Background( + child: Scaffold( + backgroundColor: Theme.of( + context, + ).extension()!.background, + appBar: AppBar( + leading: AppBarBackButton( + onPressed: () async { + if (FocusScope.of(context).hasFocus) { + FocusScope.of(context).unfocus(); + await Future.delayed(const Duration(milliseconds: 70)); + } + if (context.mounted) { + Navigator.of(context).pop(); + } + }, + ), + title: Text( + "Generate QR code", + style: STextStyles.navBarTitle(context), + ), + ), + body: SafeArea( + child: LayoutBuilder( + builder: (buildContext, constraints) { + return Padding( + padding: const EdgeInsets.only(left: 12, top: 12, right: 12), + child: SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight - 24, ), - child: SingleChildScrollView( - child: ConstrainedBox( - constraints: BoxConstraints( - minHeight: constraints.maxHeight - 24, - ), - child: IntrinsicHeight( - child: Padding( - padding: const EdgeInsets.all(4), - child: child, - ), - ), + child: IntrinsicHeight( + child: Padding( + padding: const EdgeInsets.all(4), + child: child, ), ), - ); - }, - ), - ), + ), + ), + ); + }, ), ), + ), + ), child: Padding( - padding: - isDesktop - ? const EdgeInsets.only( - top: 12, - left: 32, - right: 32, - bottom: 32, - ) - : const EdgeInsets.all(0), + padding: isDesktop + ? const EdgeInsets.only(top: 12, left: 32, right: 32, bottom: 32) + : const EdgeInsets.all(0), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: isDesktop ? MainAxisSize.min : MainAxisSize.max, @@ -369,17 +361,13 @@ class _GenerateUriQrCodeViewState extends State { if (!isDesktop) const SizedBox(height: 12), Text( "Amount (Optional)", - style: - isDesktop - ? STextStyles.desktopTextExtraExtraSmall( - context, - ).copyWith( - color: - Theme.of(context) - .extension()! - .textFieldActiveSearchIconRight, - ) - : STextStyles.smallMed12(context), + style: isDesktop + ? STextStyles.desktopTextExtraExtraSmall(context).copyWith( + color: Theme.of(context) + .extension()! + .textFieldActiveSearchIconRight, + ) + : STextStyles.smallMed12(context), textAlign: TextAlign.left, ), SizedBox(height: isDesktop ? 10 : 8), @@ -392,74 +380,75 @@ class _GenerateUriQrCodeViewState extends State { enableSuggestions: Util.isDesktop ? false : true, controller: amountController, focusNode: _amountFocusNode, - style: - isDesktop - ? STextStyles.desktopTextExtraExtraSmall( + style: isDesktop + ? STextStyles.desktopTextExtraExtraSmall(context).copyWith( + color: Theme.of( context, - ).copyWith( - color: - Theme.of( - context, - ).extension()!.textFieldDefaultText, - height: 1.8, - ) - : STextStyles.field(context), - keyboardType: - Util.isDesktop - ? null - : const TextInputType.numberWithOptions(decimal: true), + ).extension()!.textFieldDefaultText, + height: 1.8, + ) + : STextStyles.field(context), + keyboardType: Util.isDesktop + ? null + : const TextInputType.numberWithOptions(decimal: true), + inputFormatters: [ + AmountInputFormatter( + controller: amountController, + decimals: widget.coin.fractionDigits, + locale: ref.watch( + localeServiceChangeNotifierProvider.select( + (value) => value.locale, + ), + ), + ), + ], onChanged: (_) => setState(() {}), - decoration: standardInputDecoration( - "Amount", - _amountFocusNode, - context, - ).copyWith( - contentPadding: - isDesktop + decoration: + standardInputDecoration( + "Amount", + _amountFocusNode, + context, + ).copyWith( + contentPadding: isDesktop ? const EdgeInsets.only( - left: 16, - top: 11, - bottom: 12, - right: 5, - ) + left: 16, + top: 11, + bottom: 12, + right: 5, + ) : null, - suffixIcon: - amountController.text.isNotEmpty + suffixIcon: amountController.text.isNotEmpty ? Padding( - padding: const EdgeInsets.only(right: 0), - child: UnconstrainedBox( - child: Row( - children: [ - TextFieldIconButton( - child: const XIcon(), - onTap: () async { - setState(() { - amountController.text = ""; - }); - }, - ), - ], + padding: const EdgeInsets.only(right: 0), + child: UnconstrainedBox( + child: Row( + children: [ + TextFieldIconButton( + child: const XIcon(), + onTap: () async { + setState(() { + amountController.text = ""; + }); + }, + ), + ], + ), ), - ), - ) + ) : null, - ), + ), ), ), SizedBox(height: isDesktop ? 20 : 12), Text( "Note (Optional)", - style: - isDesktop - ? STextStyles.desktopTextExtraExtraSmall( - context, - ).copyWith( - color: - Theme.of(context) - .extension()! - .textFieldActiveSearchIconRight, - ) - : STextStyles.smallMed12(context), + style: isDesktop + ? STextStyles.desktopTextExtraExtraSmall(context).copyWith( + color: Theme.of(context) + .extension()! + .textFieldActiveSearchIconRight, + ) + : STextStyles.smallMed12(context), textAlign: TextAlign.left, ), SizedBox(height: isDesktop ? 10 : 8), @@ -472,73 +461,67 @@ class _GenerateUriQrCodeViewState extends State { enableSuggestions: Util.isDesktop ? false : true, controller: noteController, focusNode: _noteFocusNode, - style: - isDesktop - ? STextStyles.desktopTextExtraExtraSmall( + style: isDesktop + ? STextStyles.desktopTextExtraExtraSmall(context).copyWith( + color: Theme.of( context, - ).copyWith( - color: - Theme.of( - context, - ).extension()!.textFieldDefaultText, - height: 1.8, - ) - : STextStyles.field(context), + ).extension()!.textFieldDefaultText, + height: 1.8, + ) + : STextStyles.field(context), onChanged: (_) => setState(() {}), - decoration: standardInputDecoration( - "Note", - _noteFocusNode, - context, - ).copyWith( - contentPadding: - isDesktop + decoration: + standardInputDecoration( + "Note", + _noteFocusNode, + context, + ).copyWith( + contentPadding: isDesktop ? const EdgeInsets.only( - left: 16, - top: 11, - bottom: 12, - right: 5, - ) + left: 16, + top: 11, + bottom: 12, + right: 5, + ) : null, - suffixIcon: - noteController.text.isNotEmpty + suffixIcon: noteController.text.isNotEmpty ? Padding( - padding: const EdgeInsets.only(right: 0), - child: UnconstrainedBox( - child: Row( - children: [ - TextFieldIconButton( - child: const XIcon(), - onTap: () async { - setState(() { - noteController.text = ""; - }); - }, - ), - ], + padding: const EdgeInsets.only(right: 0), + child: UnconstrainedBox( + child: Row( + children: [ + TextFieldIconButton( + child: const XIcon(), + onTap: () async { + setState(() { + noteController.text = ""; + }); + }, + ), + ], + ), ), - ), - ) + ) : null, - ), + ), ), ), SizedBox(height: isDesktop ? 20 : 8), PrimaryButton( label: "Generate QR code", - onPressed: - isDesktop - ? () { - final uriString = _generateURI(); - if (uriString == null) { - return; - } - - setState(() { - didGenerate = true; - _uriString = uriString; - }); + onPressed: isDesktop + ? () { + final uriString = _generateURI(); + if (uriString == null) { + return; } - : onGeneratePressed, + + setState(() { + didGenerate = true; + _uriString = uriString; + }); + } + : onGeneratePressed, buttonHeight: isDesktop ? ButtonHeight.l : null, ), if (isDesktop && didGenerate) @@ -551,10 +534,9 @@ class _GenerateUriQrCodeViewState extends State { children: [ const SizedBox(height: 20), RoundedWhiteContainer( - borderColor: - Theme.of( - context, - ).extension()!.background, + borderColor: Theme.of( + context, + ).extension()!.background, width: isDesktop ? 370 : null, child: Column( children: [ @@ -575,16 +557,16 @@ class _GenerateUriQrCodeViewState extends State { ), const SizedBox(height: 12), Row( - mainAxisAlignment: - isDesktop - ? MainAxisAlignment.center - : MainAxisAlignment.start, + mainAxisAlignment: isDesktop + ? MainAxisAlignment.center + : MainAxisAlignment.start, children: [ if (!isDesktop) SecondaryButton( width: 170, - buttonHeight: - isDesktop ? ButtonHeight.l : null, + buttonHeight: isDesktop + ? ButtonHeight.l + : null, onPressed: () async { await _capturePng(false); }, @@ -593,17 +575,20 @@ class _GenerateUriQrCodeViewState extends State { Assets.svg.share, width: 20, height: 20, - color: - Theme.of(context) - .extension()! - .buttonTextSecondary, + colorFilter: .mode( + Theme.of(context) + .extension()! + .buttonTextSecondary, + .srcIn, + ), ), ), if (!isDesktop) const SizedBox(width: 16), PrimaryButton( width: 170, - buttonHeight: - isDesktop ? ButtonHeight.l : null, + buttonHeight: isDesktop + ? ButtonHeight.l + : null, onPressed: () async { // TODO: add save functionality instead of share // save works on linux at the moment @@ -614,10 +599,12 @@ class _GenerateUriQrCodeViewState extends State { Assets.svg.arrowDown, width: 20, height: 20, - color: - Theme.of(context) - .extension()! - .buttonTextPrimary, + colorFilter: .mode( + Theme.of(context) + .extension()! + .buttonTextPrimary, + .srcIn, + ), ), ), ], diff --git a/lib/pages/salvium_stake/salvium_create_stake_view.dart b/lib/pages/salvium_stake/salvium_create_stake_view.dart index 58a9a5af4d..2916b7e24a 100644 --- a/lib/pages/salvium_stake/salvium_create_stake_view.dart +++ b/lib/pages/salvium_stake/salvium_create_stake_view.dart @@ -9,6 +9,7 @@ import '../../providers/global/wallets_provider.dart'; import '../../route_generator.dart'; import '../../themes/stack_colors.dart'; import '../../utilities/amount/amount.dart'; +import '../../utilities/amount/amount_field_relocalization.dart'; import '../../utilities/amount/amount_formatter.dart'; import '../../utilities/amount/amount_input_formatter.dart'; import '../../utilities/amount/amount_unit.dart'; @@ -59,7 +60,7 @@ class _SalviumCreateStakeViewState void _parseAmount(String string) { final cryptoAmount = ref .read(pAmountFormatter(ref.read(pWalletCoin(widget.walletId)))) - .tryParse(string); + .tryParseEditable(string); if (_amount != cryptoAmount) { setState(() { @@ -289,6 +290,7 @@ class _SalviumCreateStakeViewState final locale = ref.watch( localeServiceChangeNotifierProvider.select((s) => s.locale), ); + listenForAmountRelocalization(ref.listen, controllers: [_amountController]); return ConditionalParent( condition: !Util.isDesktop, @@ -357,6 +359,7 @@ class _SalviumCreateStakeViewState textAlign: TextAlign.right, inputFormatters: [ AmountInputFormatter( + controller: _amountController, decimals: coin.fractionDigits, unit: ref.watch(pAmountUnit(coin)), locale: locale, diff --git a/lib/pages/send_view/frost_ms/frost_send_view.dart b/lib/pages/send_view/frost_ms/frost_send_view.dart index 59bdc843ef..9f3c8d592a 100644 --- a/lib/pages/send_view/frost_ms/frost_send_view.dart +++ b/lib/pages/send_view/frost_ms/frost_send_view.dart @@ -410,9 +410,8 @@ class _FrostSendViewState extends ConsumerState { sendAllTapped: () { return ref .read(pAmountFormatter(coin)) - .format( + .formatEditable( ref.read(pWalletBalance(walletId)).spendable, - withUnitName: false, ); }, ), diff --git a/lib/pages/send_view/frost_ms/recipient.dart b/lib/pages/send_view/frost_ms/recipient.dart index 7e483726a2..6c31d92e89 100644 --- a/lib/pages/send_view/frost_ms/recipient.dart +++ b/lib/pages/send_view/frost_ms/recipient.dart @@ -1,4 +1,3 @@ -import 'package:decimal/decimal.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -7,6 +6,7 @@ import '../../../providers/providers.dart'; import '../../../themes/stack_colors.dart'; import '../../../utilities/address_utils.dart'; import '../../../utilities/amount/amount.dart'; +import '../../../utilities/amount/amount_field_relocalization.dart'; import '../../../utilities/amount/amount_formatter.dart'; import '../../../utilities/amount/amount_input_formatter.dart'; import '../../../utilities/amount/amount_unit.dart'; @@ -74,7 +74,7 @@ class _RecipientState extends ConsumerState { final address = addressController.text; final amount = ref .read(pAmountFormatter(widget.coin)) - .tryParse(amountController.text); + .tryParseEditable(amountController.text); ref.read(pRecipient(widget.index).notifier).state = ( address: address, @@ -87,7 +87,7 @@ class _RecipientState extends ConsumerState { if (!_cryptoAmountChangeLock) { Amount? cryptoAmount = ref .read(pAmountFormatter(widget.coin)) - .tryParse(amountController.text); + .tryParseEditable(amountController.text); if (cryptoAmount != null) { if (ref.read(pRecipient(widget.index))?.amount != null && ref.read(pRecipient(widget.index))?.amount == cryptoAmount) { @@ -142,12 +142,18 @@ class _RecipientState extends ConsumerState { // autofill amount field if (paymentData.amount != null) { - final Amount amount = Decimal.parse( + final amount = Amount.tryParseCanonicalAmount( paymentData.amount!, - ).toAmount(fractionDigits: widget.coin.fractionDigits); - amountController.text = ref - .read(pAmountFormatter(widget.coin)) - .format(amount, withUnitName: false); + fractionDigits: widget.coin.fractionDigits, + truncateOverprecision: true, + ); + if (amount != null) { + amountController.text = ref + .read(pAmountFormatter(widget.coin)) + .formatEditable(amount); + } else { + amountController.clear(); + } } } else { addressController.text = qrResult.rawContent!.trim(); @@ -193,7 +199,7 @@ class _RecipientState extends ConsumerState { if (amount != null) { amountController.text = ref .read(pAmountFormatter(widget.coin)) - .format(amount, withUnitName: false); + .formatEditable(amount); } addressController.text = ref.read(pRecipient(widget.index))?.address ?? ""; @@ -228,6 +234,7 @@ class _RecipientState extends ConsumerState { final String locale = ref.watch( localeServiceChangeNotifierProvider.select((value) => value.locale), ); + listenForAmountRelocalization(ref.listen, controllers: [amountController]); return RoundedContainer( color: Colors.transparent, @@ -402,6 +409,7 @@ class _RecipientState extends ConsumerState { textAlign: TextAlign.right, inputFormatters: [ AmountInputFormatter( + controller: amountController, decimals: widget.coin.fractionDigits, unit: ref.watch(pAmountUnit(widget.coin)), locale: locale, diff --git a/lib/pages/send_view/send_view.dart b/lib/pages/send_view/send_view.dart index 18b8d5be2e..9475ca1ed7 100644 --- a/lib/pages/send_view/send_view.dart +++ b/lib/pages/send_view/send_view.dart @@ -34,6 +34,7 @@ import '../../themes/coin_icon_provider.dart'; import '../../themes/stack_colors.dart'; import '../../utilities/address_utils.dart'; import '../../utilities/amount/amount.dart'; +import '../../utilities/amount/amount_field_relocalization.dart'; import '../../utilities/amount/amount_formatter.dart'; import '../../utilities/amount/amount_input_formatter.dart'; import '../../utilities/amount/amount_unit.dart'; @@ -170,13 +171,21 @@ class _SendViewState extends ConsumerState { // autofill amount field if (paymentData.amount != null) { - final Amount amount = Decimal.parse( + final amount = Amount.tryParseCanonicalAmount( paymentData.amount!, - ).toAmount(fractionDigits: coin.fractionDigits); - cryptoAmountController.text = ref - .read(pAmountFormatter(coin)) - .format(amount, withUnitName: false); - ref.read(pSendAmount.notifier).state = amount; + fractionDigits: coin.fractionDigits, + truncateOverprecision: true, + ); + if (amount != null) { + cryptoAmountController.text = ref + .read(pAmountFormatter(coin)) + .formatEditable(amount); + ref.read(pSendAmount.notifier).state = amount; + } else { + cryptoAmountController.clear(); + _cachedAmountToSend = null; + ref.read(pSendAmount.notifier).state = null; + } } // Extract OP_RETURN data if present (for Rosen Bridge and other protocols) @@ -392,13 +401,14 @@ class _SendViewState extends ConsumerState { final amountString = ref .read(pAmountFormatter(coin)) - .format(amount, withUnitName: false); + .formatEditable(amount); _cryptoAmountChangeLock = true; cryptoAmountController.text = amountString; _cryptoAmountChangeLock = false; } else { amount = 0.toAmountAsRaw(fractionDigits: coin.fractionDigits); + _cachedAmountToSend = null; _cryptoAmountChangeLock = true; cryptoAmountController.text = ""; _cryptoAmountChangeLock = false; @@ -415,7 +425,7 @@ class _SendViewState extends ConsumerState { if (!_cryptoAmountChangeLock) { final cryptoAmount = ref .read(pAmountFormatter(coin)) - .tryParse(cryptoAmountController.text); + .tryParseEditable(cryptoAmountController.text); final Amount? amount; if (cryptoAmount != null) { amount = cryptoAmount; @@ -430,14 +440,17 @@ class _SendViewState extends ConsumerState { ?.value; if (price != null && price > Decimal.zero) { - baseAmountController.text = (amount.decimal * price) - .toAmount(fractionDigits: 2) - .fiatString( - locale: ref.read(localeServiceChangeNotifierProvider).locale, - ); + final fiatAmount = (amount.decimal * price).toAmount( + fractionDigits: 2, + ); + baseAmountController.text = Amount.formatEditableDecimal( + fiatAmount.decimal, + locale: ref.read(localeServiceChangeNotifierProvider).locale, + ); } } else { amount = null; + _cachedAmountToSend = null; baseAmountController.text = ""; } @@ -482,29 +495,6 @@ class _SendViewState extends ConsumerState { late Amount _currentFee; - void _setCurrentFee(String fee, bool shouldSetState) { - fee = fee.trim(); - - if (fee.startsWith("~")) { - fee = fee.substring(1); - } - if (fee.contains(" ")) { - fee = fee.split(" ").first; - } - - final value = fee.contains(",") - ? Decimal.parse( - fee.replaceFirst(",", "."), - ).toAmount(fractionDigits: coin.fractionDigits) - : Decimal.parse(fee).toAmount(fractionDigits: coin.fractionDigits); - - if (shouldSetState) { - setState(() => _currentFee = value); - } else { - _currentFee = value; - } - } - void _setValidAddressProviders(String? address) { if (isPaynymSend) { ref.read(pValidSendToAddress.notifier).state = true; @@ -538,11 +528,11 @@ class _SendViewState extends ConsumerState { } } - late Future _calculateFeesFuture; + late Future _calculateFeesFuture; - Map cachedFees = {}; - Map cachedFiroSparkFees = {}; - Map cachedFiroPublicFees = {}; + final Map<(Amount, FeeRateType), Amount> cachedFees = {}; + final Map<(Amount, FeeRateType), Amount> cachedFiroSparkFees = {}; + final Map<(Amount, FeeRateType), Amount> cachedFiroPublicFees = {}; void _setOpReturnData(String? data) { if (!mounted) { @@ -578,7 +568,9 @@ class _SendViewState extends ConsumerState { ); } - Future calculateFees(Amount amount) async { + Future calculateFees(Amount amount) async { + final feeRateType = ref.read(feeRateTypeMobileStateProvider); + final cacheKey = (amount, feeRateType); final hasOpReturnData = isFiro && ref.read(publicPrivateBalanceStateProvider) == BalanceType.public && @@ -587,18 +579,18 @@ class _SendViewState extends ConsumerState { if (isFiro) { switch (ref.read(publicPrivateBalanceStateProvider.state).state) { case BalanceType.public: - if (!hasOpReturnData && cachedFiroPublicFees[amount] != null) { - return cachedFiroPublicFees[amount]!; + if (!hasOpReturnData && cachedFiroPublicFees[cacheKey] != null) { + return cachedFiroPublicFees[cacheKey]!; } break; case BalanceType.private: - if (cachedFiroSparkFees[amount] != null) { - return cachedFiroSparkFees[amount]!; + if (cachedFiroSparkFees[cacheKey] != null) { + return cachedFiroSparkFees[cacheKey]!; } break; } - } else if (cachedFees[amount] != null) { - return cachedFees[amount]!; + } else if (cachedFees[cacheKey] != null) { + return cachedFees[cacheKey]!; } final wallet = ref.read(pWallets).getWallet(walletId); @@ -606,7 +598,7 @@ class _SendViewState extends ConsumerState { late final BigInt feeRate; - switch (ref.read(feeRateTypeMobileStateProvider.state).state) { + switch (feeRateType) { case FeeRateType.fast: feeRate = feeObject.fast; break; @@ -623,7 +615,7 @@ class _SendViewState extends ConsumerState { Amount fee; if (coin is CryptonoteCurrency) { final int specialMoneroId; - switch (ref.read(feeRateTypeMobileStateProvider.state).state) { + switch (feeRateType) { case FeeRateType.fast: specialMoneroId = (wallet as CryptonoteWallet).getTxPriorityHigh(); break; @@ -638,11 +630,8 @@ class _SendViewState extends ConsumerState { } fee = await wallet.estimateFeeFor(amount, BigInt.from(specialMoneroId)); - cachedFees[amount] = ref - .read(pAmountFormatter(coin)) - .format(fee, withUnitName: true, indicatePrecisionLoss: false); - - return cachedFees[amount]!; + cachedFees[cacheKey] = fee; + return fee; } else if (isFiro) { final firoWallet = wallet as FiroWallet; @@ -654,28 +643,20 @@ class _SendViewState extends ConsumerState { feeRate: feeRate, wallet: firoWallet, ); - final formatted = ref - .read(pAmountFormatter(coin)) - .format(fee, withUnitName: true, indicatePrecisionLoss: false); if (!hasOpReturnData) { - cachedFiroPublicFees[amount] = formatted; + cachedFiroPublicFees[cacheKey] = fee; } - return formatted; + return fee; case BalanceType.private: fee = await firoWallet.estimateFeeForSpark(amount); - cachedFiroSparkFees[amount] = ref - .read(pAmountFormatter(coin)) - .format(fee, withUnitName: true, indicatePrecisionLoss: false); - return cachedFiroSparkFees[amount]!; + cachedFiroSparkFees[cacheKey] = fee; + return fee; } } else { fee = await wallet.estimateFeeFor(amount, feeRate); - cachedFees[amount] = ref - .read(pAmountFormatter(coin)) - .format(fee, withUnitName: true, indicatePrecisionLoss: false); - - return cachedFees[amount]!; + cachedFees[cacheKey] = fee; + return fee; } } @@ -967,9 +948,10 @@ class _SendViewState extends ConsumerState { final time = Future.delayed(const Duration(milliseconds: 2500)); Future txDataFuture; + final feeRateType = ref.read(feeRateTypeMobileStateProvider); + final satsPerVByte = feeRateType.customSatsPerVByte(customFeeRate); if (isPaynymSend) { - final feeRate = ref.read(feeRateTypeMobileStateProvider); txDataFuture = (wallet as PaynymInterface).preparePaymentCodeSend( txData: TxData( paynymAccountLite: widget.accountLite!, @@ -981,8 +963,8 @@ class _SendViewState extends ConsumerState { addressType: AddressType.unknown, ), ], - satsPerVByte: isCustomFee.value ? customFeeRate : null, - feeRateType: feeRate, + satsPerVByte: satsPerVByte, + feeRateType: feeRateType, utxos: (wallet is CoinControlInterface && wallet is! SalviumWallet && @@ -1007,8 +989,8 @@ class _SendViewState extends ConsumerState { isChange: false, ), ], - feeRateType: ref.read(feeRateTypeMobileStateProvider), - satsPerVByte: isCustomFee.value ? customFeeRate : null, + feeRateType: feeRateType, + satsPerVByte: satsPerVByte, utxos: (coinControlEnabled && selectedUTXOs.isNotEmpty) ? selectedUTXOs : null, @@ -1027,8 +1009,8 @@ class _SendViewState extends ConsumerState { )!, ), ], - feeRateType: ref.read(feeRateTypeMobileStateProvider), - satsPerVByte: isCustomFee.value ? customFeeRate : null, + feeRateType: feeRateType, + satsPerVByte: satsPerVByte, utxos: (coinControlEnabled && selectedUTXOs.isNotEmpty) ? selectedUTXOs : null, @@ -1080,8 +1062,8 @@ class _SendViewState extends ConsumerState { addressType: wallet.cryptoCurrency.getAddressType(_address!)!, ), ], - feeRateType: ref.read(feeRateTypeDesktopStateProvider), - satsPerVByte: isCustomFee.value ? customFeeRate : null, + feeRateType: feeRateType, + satsPerVByte: satsPerVByte, // these will need to be mweb utxos // utxos: @@ -1105,9 +1087,9 @@ class _SendViewState extends ConsumerState { ), ], memo: memo, - feeRateType: ref.read(feeRateTypeMobileStateProvider), - satsPerVByte: isCustomFee.value ? customFeeRate : null, - ethEIP1559Fee: ethFee, + feeRateType: feeRateType, + satsPerVByte: satsPerVByte, + ethEIP1559Fee: _ethFee.value, utxos: (wallet is CoinControlInterface && wallet is! SalviumWallet && @@ -1255,15 +1237,14 @@ class _SendViewState extends ConsumerState { cryptoAmountController.text = ref .read(pAmountFormatter(coin)) - .format(amount, withUnitName: false); + .formatEditable(amount); _cryptoAmountChanged(); } bool get isPaynymSend => widget.accountLite != null; - final isCustomFee = ValueNotifier(false); int customFeeRate = 1; - EthEIP1559Fee? ethFee; + final _ethFee = ValueNotifier(null); late final bool hasFees; @@ -1277,26 +1258,25 @@ class _SendViewState extends ConsumerState { builder: (_) => TransactionFeeSelectionSheet( walletId: walletId, amount: - (Decimal.tryParse(cryptoAmountController.text) ?? - ref.watch(pSendAmount)?.decimal ?? - Decimal.zero) - .toAmount(fractionDigits: coin.fractionDigits), - updateChosen: (String fee) { - if (fee == "custom") { - if (!isCustomFee.value) { - setState(() { - isCustomFee.value = true; - }); - } + ref.read(pSendAmount) ?? + Amount.zeroWith(fractionDigits: coin.fractionDigits), + updateChosen: (feeRateType, fee) { + if (feeRateType.isCustom) { return; } - _setCurrentFee(fee, true); setState(() { - _calculateFeesFuture = Future(() => fee); - if (isCustomFee.value) { - isCustomFee.value = false; + if (fee != null) { + _currentFee = fee; + _calculateFeesFuture = Future.value(fee); + } else { + _calculateFeesFuture = calculateFees( + ref.read(pSendAmount) ?? + Amount.zeroWith(fractionDigits: coin.fractionDigits), + ); } + customFeeRate = 1; + _ethFee.value = null; }); }, ), @@ -1318,12 +1298,6 @@ class _SendViewState extends ConsumerState { ref.refresh(feeSheetSessionCacheProvider); ref.refresh(pIsExchangeAddress); }); - isCustomFee.addListener(() { - if (!isCustomFee.value) { - customFeeRate = 1; - ethFee = null; - } - }); hasFees = coin is! Epiccash && coin is! NanoCurrency && coin is! Tezos; _currentFee = 0.toAmountAsRaw(fractionDigits: coin.fractionDigits); @@ -1354,7 +1328,7 @@ class _SendViewState extends ConsumerState { _cryptoAmountChangeLock = true; cryptoAmountController.text = ref .read(pAmountFormatter(coin)) - .format(amount, withUnitName: false); + .formatEditable(amount); _cryptoAmountChangeLock = false; } sendToController.text = _data.contactLabel; @@ -1415,6 +1389,7 @@ class _SendViewState extends ConsumerState { void dispose() { _cryptoAmountChangedFeeUpdateTimer?.cancel(); _baseAmountChangedFeeUpdateTimer?.cancel(); + _ethFee.dispose(); cryptoAmountController.removeListener(onCryptoAmountChanged); baseAmountController.removeListener(_baseAmountChanged); @@ -1433,18 +1408,30 @@ class _SendViewState extends ConsumerState { _cryptoFocus.dispose(); _baseFocus.dispose(); _memoFocus.dispose(); - isCustomFee.dispose(); super.dispose(); } @override Widget build(BuildContext context) { debugPrint("BUILD: $runtimeType"); + final isCustomFee = ref.watch(feeRateTypeMobileStateProvider).isCustom; final String locale = ref.watch( localeServiceChangeNotifierProvider.select((value) => value.locale), ); + listenForAmountRelocalization( + ref.listen, + controllers: [cryptoAmountController, baseAmountController], + onRelocalized: _cryptoAmountChanged, + ); + final amountFormatter = ref.watch(pAmountFormatter(coin)); final balType = ref.watch(publicPrivateBalanceStateProvider); + // ethFee is checked in the ValueListenableBuilder around the preview + // button so fee keystrokes don't rebuild this whole view. + final previewEnabled = + ref.watch(pPreviewTxButtonEnabled(coin)) && + (ref.watch(pOpReturnData) == null || balType != BalanceType.private); + final needsEthFee = isEth && isCustomFee; final isMwebEnabled = ref.watch( pWalletInfo(walletId).select((s) => s.isMwebEnabled), @@ -1642,10 +1629,7 @@ class _SendViewState extends ConsumerState { onTap: () { cryptoAmountController.text = ref .read(pAmountFormatter(coin)) - .format( - amount, - withUnitName: false, - ); + .formatEditable(amount); }, child: Container( color: Colors.transparent, @@ -2249,6 +2233,7 @@ class _SendViewState extends ConsumerState { textAlign: TextAlign.right, inputFormatters: [ AmountInputFormatter( + controller: cryptoAmountController, decimals: coin.fractionDigits, unit: ref.watch(pAmountUnit(coin)), locale: locale, @@ -2319,6 +2304,7 @@ class _SendViewState extends ConsumerState { textAlign: TextAlign.right, inputFormatters: [ AmountInputFormatter( + controller: baseAmountController, decimals: 2, locale: locale, ), @@ -2623,12 +2609,18 @@ class _SendViewState extends ConsumerState { ConnectionState .done && snapshot.hasData) { - _setCurrentFee( - snapshot.data!, - false, - ); + _currentFee = + snapshot.data!; + final formattedFee = + amountFormatter.format( + snapshot.data!, + withUnitName: + true, + indicatePrecisionLoss: + false, + ); return Text( - "~${snapshot.data!}", + "~$formattedFee", style: STextStyles.itemSubtitle( context, @@ -2678,14 +2670,21 @@ class _SendViewState extends ConsumerState { .done && snapshot .hasData) { - _setCurrentFee( - snapshot.data!, - false, - ); + _currentFee = + snapshot.data!; + final formattedFee = + amountFormatter.format( + snapshot + .data!, + withUnitName: + true, + indicatePrecisionLoss: + false, + ); return Text( - isCustomFee.value + isCustomFee ? "" - : "~${snapshot.data!}", + : "~$formattedFee", style: STextStyles.itemSubtitle( context, @@ -2722,7 +2721,7 @@ class _SendViewState extends ConsumerState { ), ], ), - if (isCustomFee.value && !isEth) + if (isCustomFee && !isEth) Padding( padding: const EdgeInsets.only( bottom: 12, @@ -2735,12 +2734,15 @@ class _SendViewState extends ConsumerState { }, ), ), - if (isCustomFee.value && isEth) + if (isCustomFee && isEth) const SizedBox(height: 12), - if (isCustomFee.value && isEth) + if (isCustomFee && isEth) EthFeeForm( + locale: locale, minGasLimit: kEthereumMinGasLimit, - stateChanged: (fee) => ethFee = fee, + stateChanged: (fee) { + _ethFee.value = fee; + }, ), const Spacer(), const SizedBox(height: 12), @@ -2765,31 +2767,39 @@ class _SendViewState extends ConsumerState { ), ), ), - TextButton( - onPressed: - ref.watch(pPreviewTxButtonEnabled(coin)) && - (ref.watch(pOpReturnData) == null || - balType != BalanceType.private) - ? isMwcSlatepack - ? _createSlatepack - : isEpicSlatepack - ? _createEpicSlatepack - : _previewTransaction - : null, - style: - ref.watch(pPreviewTxButtonEnabled(coin)) && - (ref.watch(pOpReturnData) == null || - balType != BalanceType.private) - ? Theme.of(context) - .extension()! - .getPrimaryEnabledButtonStyle(context) - : Theme.of(context) - .extension()! - .getPrimaryDisabledButtonStyle(context), - child: Text( - isSlatepackMode ? "Create slate" : "Preview", - style: STextStyles.button(context), - ), + ValueListenableBuilder( + valueListenable: _ethFee, + builder: (context, ethFee, _) { + final enabled = + previewEnabled && + (!needsEthFee || ethFee != null); + return TextButton( + onPressed: enabled + ? isMwcSlatepack + ? _createSlatepack + : isEpicSlatepack + ? _createEpicSlatepack + : _previewTransaction + : null, + style: enabled + ? Theme.of(context) + .extension()! + .getPrimaryEnabledButtonStyle( + context, + ) + : Theme.of(context) + .extension()! + .getPrimaryDisabledButtonStyle( + context, + ), + child: Text( + isSlatepackMode + ? "Create slate" + : "Preview", + style: STextStyles.button(context), + ), + ); + }, ), const SizedBox(height: 16), ], diff --git a/lib/pages/send_view/sol_token_send_view.dart b/lib/pages/send_view/sol_token_send_view.dart index 6187d4c53a..4bfca58ef1 100644 --- a/lib/pages/send_view/sol_token_send_view.dart +++ b/lib/pages/send_view/sol_token_send_view.dart @@ -25,8 +25,10 @@ import '../../route_generator.dart'; import '../../themes/stack_colors.dart'; import '../../utilities/address_utils.dart'; import '../../utilities/amount/amount.dart'; +import '../../utilities/amount/amount_field_relocalization.dart'; import '../../utilities/amount/amount_formatter.dart'; import '../../utilities/amount/amount_input_formatter.dart'; +import '../../utilities/amount/amount_unit.dart'; import '../../utilities/assets.dart'; import '../../utilities/barcode_scanner_interface.dart'; import '../../utilities/clipboard_interface.dart'; @@ -56,6 +58,21 @@ import 'confirm_transaction_view.dart'; import 'sub_widgets/building_transaction_dialog.dart'; import 'sub_widgets/transaction_fee_selection_sheet.dart'; +Amount? parseMobileSolTokenAmount( + String value, { + required String locale, + required CryptoCurrency coin, + required SolContract tokenContract, +}) => AmountUnit.normal.tryParse( + value, + locale: locale, + coin: coin, + tokenContract: tokenContract, +); + +Amount? parseMobileSolTokenFiatAmount(String value, {required String locale}) => + Amount.tryParseFiatString(value, locale: locale); + class SolTokenSendView extends ConsumerStatefulWidget { const SolTokenSendView({ super.key, @@ -109,8 +126,7 @@ class _SolTokenSendViewState extends ConsumerState { Timer? _cryptoAmountChangedFeeUpdateTimer; Timer? _baseAmountChangedFeeUpdateTimer; - late Future _calculateFeesFuture; - String cachedFees = ""; + late Future _calculateFeesFuture; void _onTokenSendViewPasteAddressFieldButtonPressed() async { final ClipboardData? data = await clipboard.getData(Clipboard.kTextPlain); @@ -164,17 +180,22 @@ class _SolTokenSendViewState extends ConsumerState { if (paymentData.amount != null) { final tokenWallet = ref.read(pCurrentSolanaTokenWallet); if (tokenWallet != null) { - final Amount amount = Decimal.parse( + final amount = Amount.tryParseCanonicalAmount( paymentData.amount!, - ).toAmount(fractionDigits: tokenWallet.tokenDecimals); - cryptoAmountController.text = ref - .read(pAmountFormatter(Solana(CryptoCurrencyNetwork.main))) - .format( - amount, - withUnitName: false, - indicatePrecisionLoss: false, - ); - _amountToSend = amount; + fractionDigits: tokenWallet.tokenDecimals, + truncateOverprecision: true, + ); + if (amount != null) { + cryptoAmountController.text = Amount.formatEditableDecimal( + amount.decimal, + locale: ref.read(localeServiceChangeNotifierProvider).locale, + ); + _amountToSend = amount; + } else { + cryptoAmountController.clear(); + _amountToSend = null; + _cachedAmountToSend = null; + } } } @@ -217,8 +238,19 @@ class _SolTokenSendViewState extends ConsumerState { } } + String _formatTokenBalance(Amount amount) { + final tokenWallet = ref.read(pCurrentSolanaTokenWallet)!; + return AmountUnit.normal.displayAmount( + amount: amount, + locale: ref.read(localeServiceChangeNotifierProvider).locale, + coin: tokenWallet.cryptoCurrency, + maxDecimalPlaces: tokenWallet.tokenDecimals, + tokenContract: tokenWallet.solContract, + ); + } + void _onFiatAmountFieldChanged(String baseAmountString) { - final baseAmount = Amount.tryParseFiatString( + final baseAmount = parseMobileSolTokenFiatAmount( baseAmountString, locale: ref.read(localeServiceChangeNotifierProvider).locale, ); @@ -249,12 +281,14 @@ class _SolTokenSendViewState extends ConsumerState { _cachedAmountToSend = _amountToSend; _cryptoAmountChangeLock = true; - cryptoAmountController.text = ref - .read(pAmountFormatter(Solana(CryptoCurrencyNetwork.main))) - .format(_amountToSend!, withUnitName: false); + cryptoAmountController.text = Amount.formatEditableDecimal( + _amountToSend!.decimal, + locale: ref.read(localeServiceChangeNotifierProvider).locale, + ); _cryptoAmountChangeLock = false; } else { _amountToSend = Amount.zero; + _cachedAmountToSend = null; _cryptoAmountChangeLock = true; cryptoAmountController.text = ""; _cryptoAmountChangeLock = false; @@ -267,9 +301,12 @@ class _SolTokenSendViewState extends ConsumerState { final tokenWallet = ref.read(pCurrentSolanaTokenWallet); if (tokenWallet == null) return; - final cryptoAmount = Decimal.tryParse( + final cryptoAmount = parseMobileSolTokenAmount( cryptoAmountController.text, - )?.toAmount(fractionDigits: tokenWallet.tokenDecimals); + locale: ref.read(localeServiceChangeNotifierProvider).locale, + coin: tokenWallet.cryptoCurrency, + tokenContract: tokenWallet.solContract, + ); if (cryptoAmount != null) { _amountToSend = cryptoAmount; if (_cachedAmountToSend != null && @@ -284,14 +321,17 @@ class _SolTokenSendViewState extends ConsumerState { ?.value; if (price != null && price > Decimal.zero) { - baseAmountController.text = (_amountToSend!.decimal * price) - .toAmount(fractionDigits: 2) - .fiatString( - locale: ref.read(localeServiceChangeNotifierProvider).locale, - ); + final fiatAmount = (_amountToSend!.decimal * price).toAmount( + fractionDigits: 2, + ); + baseAmountController.text = Amount.formatEditableDecimal( + fiatAmount.decimal, + locale: ref.read(localeServiceChangeNotifierProvider).locale, + ); } } else { _amountToSend = null; + _cachedAmountToSend = null; baseAmountController.text = ""; } @@ -340,11 +380,16 @@ class _SolTokenSendViewState extends ConsumerState { (isValidAddress && amount != null && amount > Amount.zero); } - Future calculateFees() async { + Future calculateFees() async { + final solana = Solana(CryptoCurrencyNetwork.main); + final minimumFee = Amount( + rawValue: BigInt.from(5000), + fractionDigits: solana.fractionDigits, + ); try { final wallet = ref.read(pCurrentSolanaTokenWallet); if (wallet == null) { - return "0.000005 SOL"; + return minimumFee; } final feeObject = await wallet.fees; @@ -366,19 +411,14 @@ class _SolTokenSendViewState extends ConsumerState { } final Amount fee = await wallet.estimateFeeFor(Amount.zero, feeRate); - cachedFees = ref - .read(pAmountFormatter(Solana(CryptoCurrencyNetwork.main))) - .format(fee, withUnitName: true, indicatePrecisionLoss: false); - - return cachedFees; + return fee; } catch (e, s) { Logging.instance.w( "Failed to calculate Solana token fees: ", error: e, stackTrace: s, ); - // Return minimum fee as fallback. - return "0.000005 SOL"; + return minimumFee; } } @@ -581,7 +621,15 @@ class _SolTokenSendViewState extends ConsumerState { if (_data != null) { if (_data.amount != null) { - cryptoAmountController.text = _data.amount!.toString(); + final tokenWallet = ref.read(pCurrentSolanaTokenWallet)!; + final amount = Amount.fromDecimal( + _data.amount!, + fractionDigits: tokenWallet.tokenDecimals, + ); + cryptoAmountController.text = Amount.formatEditableDecimal( + amount.decimal, + locale: ref.read(localeServiceChangeNotifierProvider).locale, + ); } sendToController.text = _data.contactLabel; _address = _data.address.trim(); @@ -620,6 +668,11 @@ class _SolTokenSendViewState extends ConsumerState { final String locale = ref.watch( localeServiceChangeNotifierProvider.select((value) => value.locale), ); + listenForAmountRelocalization( + ref.listen, + controllers: [cryptoAmountController, baseAmountController], + onRelocalized: _cryptoAmountChanged, + ); final tokenWallet = ref.watch(pCurrentSolanaTokenWallet); @@ -731,25 +784,18 @@ class _SolTokenSendViewState extends ConsumerState { const Spacer(), GestureDetector( onTap: () { - cryptoAmountController.text = ref - .watch( - pAmountFormatter( - Solana( - CryptoCurrencyNetwork.main, - ), - ), + final amount = ref + .read( + pSolanaTokenBalance(( + walletId: widget.walletId, + tokenMint: tokenMint, + )), ) - .format( - ref - .read( - pSolanaTokenBalance(( - walletId: widget.walletId, - tokenMint: tokenMint, - )), - ) - .spendable, - withUnitName: false, - indicatePrecisionLoss: true, + .spendable; + cryptoAmountController.text = + Amount.formatEditableDecimal( + amount.decimal, + locale: locale, ); }, child: Container( @@ -759,27 +805,17 @@ class _SolTokenSendViewState extends ConsumerState { CrossAxisAlignment.end, children: [ Text( - ref - .watch( - pAmountFormatter( - Solana( - CryptoCurrencyNetwork - .main, - ), - ), - ) - .format( - ref - .watch( - pSolanaTokenBalance(( - walletId: - widget.walletId, - tokenMint: - tokenMint, - )), - ) - .spendable, - ), + _formatTokenBalance( + ref + .watch( + pSolanaTokenBalance(( + walletId: + widget.walletId, + tokenMint: tokenMint, + )), + ) + .spendable, + ), style: STextStyles.titleBold12( context, ).copyWith(fontSize: 10), @@ -1063,14 +1099,9 @@ class _SolTokenSendViewState extends ConsumerState { textAlign: TextAlign.right, inputFormatters: [ AmountInputFormatter( + controller: cryptoAmountController, decimals: tokenWallet.tokenDecimals, - // TODO: Implement token-specific unit lookup - // similar to Ethereum's pAmountUnit(coin).unitForContract(tokenContract) - unit: ref.watch( - pAmountUnit( - Solana(CryptoCurrencyNetwork.main), - ), - ), + unit: AmountUnit.normal, locale: locale, ), ], @@ -1127,6 +1158,7 @@ class _SolTokenSendViewState extends ConsumerState { textAlign: TextAlign.right, inputFormatters: [ AmountInputFormatter( + controller: baseAmountController, decimals: 2, locale: locale, ), @@ -1258,21 +1290,17 @@ class _SolTokenSendViewState extends ConsumerState { walletId: walletId, isToken: true, amount: - (Decimal.tryParse( - cryptoAmountController - .text, - ) ?? - Decimal.zero) - .toAmount( - fractionDigits: - tokenWallet - .tokenDecimals, - ), - updateChosen: (String fee) { + _amountToSend ?? + Amount.zeroWith( + fractionDigits: tokenWallet + .tokenDecimals, + ), + updateChosen: (_, fee) { setState(() { - _calculateFeesFuture = Future( - () => fee, - ); + _calculateFeesFuture = + fee == null + ? calculateFees() + : Future.value(fee); }); }, ), @@ -1303,8 +1331,21 @@ class _SolTokenSendViewState extends ConsumerState { if (snapshot.connectionState == ConnectionState.done && snapshot.hasData) { + final formattedFee = ref + .watch( + pAmountFormatter( + tokenWallet + .cryptoCurrency, + ), + ) + .format( + snapshot.data!, + withUnitName: true, + indicatePrecisionLoss: + false, + ); return Text( - "~${snapshot.data!}", + "~$formattedFee", style: STextStyles.itemSubtitle( context, diff --git a/lib/pages/send_view/sub_widgets/transaction_fee_selection_sheet.dart b/lib/pages/send_view/sub_widgets/transaction_fee_selection_sheet.dart index 387138d8cf..732e5e1c53 100644 --- a/lib/pages/send_view/sub_widgets/transaction_fee_selection_sheet.dart +++ b/lib/pages/send_view/sub_widgets/transaction_fee_selection_sheet.dart @@ -22,7 +22,6 @@ import '../../../utilities/amount/amount.dart'; import '../../../utilities/amount/amount_formatter.dart'; import '../../../utilities/constants.dart'; import '../../../utilities/enums/fee_rate_type_enum.dart'; -import '../../../utilities/logger.dart'; import '../../../utilities/text_styles.dart'; import '../../../wallets/crypto_currency/crypto_currency.dart'; import '../../../wallets/crypto_currency/intermediate/cryptonote_currency.dart'; @@ -58,7 +57,7 @@ class TransactionFeeSelectionSheet extends ConsumerStatefulWidget { final String walletId; final Amount amount; - final Function updateChosen; + final void Function(FeeRateType feeRateType, Amount? fee) updateChosen; final bool isToken; @override @@ -80,6 +79,16 @@ class _TransactionFeeSelectionSheetState "Calculating...", ]; + void _selectFeeRate(FeeRateType feeRateType) { + ref.read(feeRateTypeMobileStateProvider.state).state = feeRateType; + widget.updateChosen( + feeRateType, + feeRateType.isCustom ? null : getAmount(feeRateType), + ); + + Navigator.of(context).pop(); + } + Amount _addFiroOpReturnFee({ required Amount fee, required BigInt feeRate, @@ -349,23 +358,7 @@ class _TransactionFeeSelectionSheetState ), const SizedBox(height: 16), GestureDetector( - onTap: () { - final state = ref - .read(feeRateTypeMobileStateProvider.state) - .state; - if (state != FeeRateType.fast) { - ref.read(feeRateTypeMobileStateProvider.state).state = - FeeRateType.fast; - } - final String? fee = getAmount( - FeeRateType.fast, - wallet.info.coin, - ); - if (fee != null) { - widget.updateChosen(fee); - } - Navigator.of(context).pop(); - }, + onTap: () => _selectFeeRate(FeeRateType.fast), child: Container( color: Colors.transparent, child: Row( @@ -387,17 +380,8 @@ class _TransactionFeeSelectionSheetState feeRateTypeMobileStateProvider.state, ) .state, - onChanged: (x) { - ref - .read( - feeRateTypeMobileStateProvider - .state, - ) - .state = - FeeRateType.fast; - - Navigator.of(context).pop(); - }, + onChanged: (_) => + _selectFeeRate(FeeRateType.fast), ), ), ], @@ -486,23 +470,7 @@ class _TransactionFeeSelectionSheetState ), const SizedBox(height: 16), GestureDetector( - onTap: () { - final state = ref - .read(feeRateTypeMobileStateProvider.state) - .state; - if (state != FeeRateType.average) { - ref.read(feeRateTypeMobileStateProvider.state).state = - FeeRateType.average; - } - final String? fee = getAmount( - FeeRateType.average, - coin, - ); - if (fee != null) { - widget.updateChosen(fee); - } - Navigator.of(context).pop(); - }, + onTap: () => _selectFeeRate(FeeRateType.average), child: Container( color: Colors.transparent, child: Row( @@ -523,16 +491,8 @@ class _TransactionFeeSelectionSheetState feeRateTypeMobileStateProvider.state, ) .state, - onChanged: (x) { - ref - .read( - feeRateTypeMobileStateProvider - .state, - ) - .state = - FeeRateType.average; - Navigator.of(context).pop(); - }, + onChanged: (_) => + _selectFeeRate(FeeRateType.average), ), ), ], @@ -621,20 +581,7 @@ class _TransactionFeeSelectionSheetState ), const SizedBox(height: 16), GestureDetector( - onTap: () { - final state = ref - .read(feeRateTypeMobileStateProvider.state) - .state; - if (state != FeeRateType.slow) { - ref.read(feeRateTypeMobileStateProvider.state).state = - FeeRateType.slow; - } - final String? fee = getAmount(FeeRateType.slow, coin); - if (fee != null) { - widget.updateChosen(fee); - } - Navigator.of(context).pop(); - }, + onTap: () => _selectFeeRate(FeeRateType.slow), child: Container( color: Colors.transparent, child: Row( @@ -655,16 +602,8 @@ class _TransactionFeeSelectionSheetState feeRateTypeMobileStateProvider.state, ) .state, - onChanged: (x) { - ref - .read( - feeRateTypeMobileStateProvider - .state, - ) - .state = - FeeRateType.slow; - Navigator.of(context).pop(); - }, + onChanged: (_) => + _selectFeeRate(FeeRateType.slow), ), ), ], @@ -754,20 +693,7 @@ class _TransactionFeeSelectionSheetState const SizedBox(height: 24), if (wallet is ElectrumXInterface || coin is Ethereum) GestureDetector( - onTap: () { - final state = ref - .read(feeRateTypeMobileStateProvider.state) - .state; - if (state != FeeRateType.custom) { - ref - .read(feeRateTypeMobileStateProvider.state) - .state = - FeeRateType.custom; - } - widget.updateChosen("custom"); - - Navigator.of(context).pop(); - }, + onTap: () => _selectFeeRate(FeeRateType.custom), child: Container( color: Colors.transparent, child: Row( @@ -789,16 +715,8 @@ class _TransactionFeeSelectionSheetState .state, ) .state, - onChanged: (x) { - ref - .read( - feeRateTypeMobileStateProvider - .state, - ) - .state = - FeeRateType.custom; - Navigator.of(context).pop(); - }, + onChanged: (_) => + _selectFeeRate(FeeRateType.custom), ), ), ], @@ -839,50 +757,16 @@ class _TransactionFeeSelectionSheetState ); } - String? getAmount(FeeRateType feeRateType, CryptoCurrency coin) { - try { - switch (feeRateType) { - case FeeRateType.fast: - if (ref.read(feeSheetSessionCacheProvider).fast[amount] != null) { - return ref - .read(pAmountFormatter(coin)) - .format( - ref.read(feeSheetSessionCacheProvider).fast[amount]!, - indicatePrecisionLoss: false, - withUnitName: false, - ); - } - return null; - - case FeeRateType.average: - if (ref.read(feeSheetSessionCacheProvider).average[amount] != null) { - return ref - .read(pAmountFormatter(coin)) - .format( - ref.read(feeSheetSessionCacheProvider).average[amount]!, - indicatePrecisionLoss: false, - withUnitName: false, - ); - } - return null; - - case FeeRateType.slow: - if (ref.read(feeSheetSessionCacheProvider).slow[amount] != null) { - return ref - .read(pAmountFormatter(coin)) - .format( - ref.read(feeSheetSessionCacheProvider).slow[amount]!, - indicatePrecisionLoss: false, - withUnitName: false, - ); - } - return null; - case FeeRateType.custom: - return null; - } - } catch (e, s) { - Logging.instance.w("$e $s", error: e, stackTrace: s); - return null; + Amount? getAmount(FeeRateType feeRateType) { + switch (feeRateType) { + case FeeRateType.fast: + return ref.read(feeSheetSessionCacheProvider).fast[amount]; + case FeeRateType.average: + return ref.read(feeSheetSessionCacheProvider).average[amount]; + case FeeRateType.slow: + return ref.read(feeSheetSessionCacheProvider).slow[amount]; + case FeeRateType.custom: + return null; } } } diff --git a/lib/pages/send_view/token_send_view.dart b/lib/pages/send_view/token_send_view.dart index 3d30fc5f6a..5d25e248b6 100644 --- a/lib/pages/send_view/token_send_view.dart +++ b/lib/pages/send_view/token_send_view.dart @@ -25,6 +25,7 @@ import '../../route_generator.dart'; import '../../themes/stack_colors.dart'; import '../../utilities/address_utils.dart'; import '../../utilities/amount/amount.dart'; +import '../../utilities/amount/amount_field_relocalization.dart'; import '../../utilities/amount/amount_formatter.dart'; import '../../utilities/amount/amount_input_formatter.dart'; import '../../utilities/amount/amount_unit.dart'; @@ -115,12 +116,9 @@ class _TokenSendViewState extends ConsumerState { Timer? _cryptoAmountChangedFeeUpdateTimer; Timer? _baseAmountChangedFeeUpdateTimer; - late Future _calculateFeesFuture; - String cachedFees = ""; + late Future _calculateFeesFuture; - final isCustomFee = ValueNotifier(false); - - EthEIP1559Fee? ethFee; + final _ethFee = ValueNotifier(null); void _onTokenSendViewPasteAddressFieldButtonPressed() async { final ClipboardData? data = await clipboard.getData(Clipboard.kTextPlain); @@ -187,17 +185,21 @@ class _TokenSendViewState extends ConsumerState { // autofill amount field if (paymentData.amount != null) { - final Amount amount = Decimal.parse( + final amount = Amount.tryParseCanonicalAmount( paymentData.amount!, - ).toAmount(fractionDigits: tokenContract.decimals); - cryptoAmountController.text = ref - .read(pAmountFormatter(coin)) - .format( - amount, - withUnitName: false, - indicatePrecisionLoss: false, - ); - _amountToSend = amount; + fractionDigits: tokenContract.decimals, + truncateOverprecision: true, + ); + if (amount != null) { + cryptoAmountController.text = ref + .read(pAmountFormatter(coin)) + .formatEditable(amount); + _amountToSend = amount; + } else { + cryptoAmountController.clear(); + _amountToSend = null; + _cachedAmountToSend = null; + } } _updatePreviewButtonState(_address, _amountToSend); @@ -275,10 +277,11 @@ class _TokenSendViewState extends ConsumerState { _cryptoAmountChangeLock = true; cryptoAmountController.text = ref .read(pAmountFormatter(coin)) - .format(_amountToSend!, withUnitName: false); + .formatEditable(_amountToSend!); _cryptoAmountChangeLock = false; } else { _amountToSend = Amount.zero; + _cachedAmountToSend = null; _cryptoAmountChangeLock = true; cryptoAmountController.text = ""; _cryptoAmountChangeLock = false; @@ -295,7 +298,10 @@ class _TokenSendViewState extends ConsumerState { if (!_cryptoAmountChangeLock) { final cryptoAmount = ref .read(pAmountFormatter(coin)) - .tryParse(cryptoAmountController.text, tokenContract: tokenContract); + .tryParseEditable( + cryptoAmountController.text, + tokenContract: tokenContract, + ); if (cryptoAmount != null) { _amountToSend = cryptoAmount; if (_cachedAmountToSend != null && @@ -310,14 +316,17 @@ class _TokenSendViewState extends ConsumerState { ?.value; if (price != null && price > Decimal.zero) { - baseAmountController.text = (_amountToSend!.decimal * price) - .toAmount(fractionDigits: 2) - .fiatString( - locale: ref.read(localeServiceChangeNotifierProvider).locale, - ); + final fiatAmount = (_amountToSend!.decimal * price).toAmount( + fractionDigits: 2, + ); + baseAmountController.text = Amount.formatEditableDecimal( + fiatAmount.decimal, + locale: ref.read(localeServiceChangeNotifierProvider).locale, + ); } } else { _amountToSend = null; + _cachedAmountToSend = null; baseAmountController.text = ""; } @@ -370,7 +379,7 @@ class _TokenSendViewState extends ConsumerState { (isValidAddress && amount != null && amount > Amount.zero); } - Future calculateFees() async { + Future calculateFees() async { final wallet = ref.read(pCurrentTokenWallet)!; final feeObject = await wallet.fees; @@ -391,11 +400,7 @@ class _TokenSendViewState extends ConsumerState { } final Amount fee = await wallet.estimateFeeFor(Amount.zero, feeRate); - cachedFees = ref - .read(pAmountFormatter(coin)) - .format(fee, withUnitName: true, indicatePrecisionLoss: false); - - return cachedFees; + return fee; } Future _previewTransaction() async { @@ -501,7 +506,7 @@ class _TokenSendViewState extends ConsumerState { ], feeRateType: ref.read(feeRateTypeMobileStateProvider), note: noteController.text, - ethEIP1559Fee: ethFee, + ethEIP1559Fee: _ethFee.value, ), ); @@ -586,9 +591,6 @@ class _TokenSendViewState extends ConsumerState { @override void initState() { ref.refresh(feeSheetSessionCacheProvider); - isCustomFee.addListener(() { - if (!isCustomFee.value) ethFee = null; - }); _calculateFeesFuture = calculateFees(); _data = widget.autoFillData; @@ -609,7 +611,11 @@ class _TokenSendViewState extends ConsumerState { if (_data != null) { if (_data.amount != null) { - cryptoAmountController.text = _data.amount!.toString(); + cryptoAmountController.text = ref + .read(pAmountFormatter(coin)) + .formatEditable( + _data.amount!.toAmount(fractionDigits: tokenContract.decimals), + ); } sendToController.text = _data.contactLabel; _address = _data.address.trim(); @@ -623,6 +629,7 @@ class _TokenSendViewState extends ConsumerState { void dispose() { _cryptoAmountChangedFeeUpdateTimer?.cancel(); _baseAmountChangedFeeUpdateTimer?.cancel(); + _ethFee.dispose(); cryptoAmountController.removeListener(onCryptoAmountChanged); baseAmountController.removeListener(_baseAmountChanged); @@ -637,16 +644,27 @@ class _TokenSendViewState extends ConsumerState { _addressFocusNode.dispose(); _cryptoFocus.dispose(); _baseFocus.dispose(); - isCustomFee.dispose(); super.dispose(); } @override Widget build(BuildContext context) { debugPrint("BUILD: $runtimeType"); + final isCustomFee = ref.watch(feeRateTypeMobileStateProvider).isCustom; final String locale = ref.watch( localeServiceChangeNotifierProvider.select((value) => value.locale), ); + listenForAmountRelocalization( + ref.listen, + controllers: [cryptoAmountController, baseAmountController], + onRelocalized: _cryptoAmountChanged, + ); + // ethFee is checked in the ValueListenableBuilder around the preview + // button so fee keystrokes don't rebuild this whole view. + final previewEnabled = ref + .watch(previewTokenTxButtonStateProvider.state) + .state; + final needsEthFee = isCustomFee; Decimal? price; if (ref.watch(prefsChangeNotifierProvider.select((s) => s.externalCalls))) { @@ -736,7 +754,7 @@ class _TokenSendViewState extends ConsumerState { onTap: () { cryptoAmountController.text = ref .watch(pAmountFormatter(coin)) - .format( + .formatEditable( ref .read( pTokenBalance(( @@ -746,9 +764,6 @@ class _TokenSendViewState extends ConsumerState { )), ) .spendable, - tokenContract: tokenContract, - withUnitName: false, - indicatePrecisionLoss: true, ); }, child: Container( @@ -995,6 +1010,7 @@ class _TokenSendViewState extends ConsumerState { textAlign: TextAlign.right, inputFormatters: [ AmountInputFormatter( + controller: cryptoAmountController, decimals: tokenContract.decimals, unit: ref.watch(pAmountUnit(coin)), locale: locale, @@ -1062,6 +1078,7 @@ class _TokenSendViewState extends ConsumerState { textAlign: TextAlign.right, inputFormatters: [ AmountInputFormatter( + controller: baseAmountController, decimals: 2, locale: locale, ), @@ -1157,7 +1174,7 @@ class _TokenSendViewState extends ConsumerState { ), const SizedBox(height: 12), Text( - "Transaction fee ${isCustomFee.value ? "" : "(max)"}", + "Transaction fee ${isCustomFee ? "" : "(max)"}", style: STextStyles.smallMed12(context), textAlign: TextAlign.left, ), @@ -1200,33 +1217,25 @@ class _TokenSendViewState extends ConsumerState { walletId: walletId, isToken: true, amount: - (Decimal.tryParse( - cryptoAmountController - .text, - ) ?? - Decimal.zero) - .toAmount( - fractionDigits: - tokenContract - .decimals, - ), - updateChosen: (String fee) { - if (fee == "custom") { - if (!isCustomFee.value) { - setState(() { - isCustomFee.value = true; - }); - } + _amountToSend ?? + Amount.zeroWith( + fractionDigits: + tokenContract.decimals, + ), + updateChosen: (feeRateType, fee) { + if (feeRateType.isCustom) { return; } setState(() { - _calculateFeesFuture = Future( - () => fee, - ); - if (isCustomFee.value) { - isCustomFee.value = false; + if (fee != null) { + _calculateFeesFuture = + Future.value(fee); + } else { + _calculateFeesFuture = + calculateFees(); } + _ethFee.value = null; }); }, ), @@ -1257,10 +1266,20 @@ class _TokenSendViewState extends ConsumerState { if (snapshot.connectionState == ConnectionState.done && snapshot.hasData) { + final formattedFee = ref + .watch( + pAmountFormatter(coin), + ) + .format( + snapshot.data!, + withUnitName: true, + indicatePrecisionLoss: + false, + ); return Text( - isCustomFee.value + isCustomFee ? "" - : "~${snapshot.data!}", + : "~$formattedFee", style: STextStyles.itemSubtitle( context, @@ -1302,39 +1321,44 @@ class _TokenSendViewState extends ConsumerState { ), ], ), - if (isCustomFee.value) const SizedBox(height: 12), - if (isCustomFee.value) + if (isCustomFee) const SizedBox(height: 12), + if (isCustomFee) EthFeeForm( + locale: locale, minGasLimit: kEthereumTokenMinGasLimit, - stateChanged: (value) => ethFee = value, + stateChanged: (value) { + _ethFee.value = value; + }, ), const Spacer(), const SizedBox(height: 12), - TextButton( - onPressed: - ref - .watch( - previewTokenTxButtonStateProvider.state, - ) - .state - ? _previewTransaction - : null, - style: - ref - .watch( - previewTokenTxButtonStateProvider.state, - ) - .state - ? Theme.of(context) - .extension()! - .getPrimaryEnabledButtonStyle(context) - : Theme.of(context) - .extension()! - .getPrimaryDisabledButtonStyle(context), - child: Text( - "Preview", - style: STextStyles.button(context), - ), + ValueListenableBuilder( + valueListenable: _ethFee, + builder: (context, ethFee, _) { + final enabled = + previewEnabled && + (!needsEthFee || ethFee != null); + return TextButton( + onPressed: enabled + ? _previewTransaction + : null, + style: enabled + ? Theme.of(context) + .extension()! + .getPrimaryEnabledButtonStyle( + context, + ) + : Theme.of(context) + .extension()! + .getPrimaryDisabledButtonStyle( + context, + ), + child: Text( + "Preview", + style: STextStyles.button(context), + ), + ); + }, ), const SizedBox(height: 16), ], diff --git a/lib/pages/settings_views/global_settings_view/manage_nodes_views/add_edit_node_view.dart b/lib/pages/settings_views/global_settings_view/manage_nodes_views/add_edit_node_view.dart index bd009f1710..7cbbcf20ab 100644 --- a/lib/pages/settings_views/global_settings_view/manage_nodes_views/add_edit_node_view.dart +++ b/lib/pages/settings_views/global_settings_view/manage_nodes_views/add_edit_node_view.dart @@ -828,16 +828,11 @@ class _NodeFormState extends ConsumerState { } bool get canSave { - // 65535 is max tcp port return _nameController.text.isNotEmpty && canTestConnection; } bool get canTestConnection { - // 65535 is max tcp port - return _hostController.text.isNotEmpty && - port != null && - port! >= 0 && - port! <= 65535; + return _hostController.text.isNotEmpty && isValidNodePort(port); } bool enableField(TextEditingController controller) { diff --git a/lib/pages/settings_views/global_settings_view/stack_backup_views/create_auto_backup_view.dart b/lib/pages/settings_views/global_settings_view/stack_backup_views/create_auto_backup_view.dart index 9e32ad392c..5c1be8d153 100644 --- a/lib/pages/settings_views/global_settings_view/stack_backup_views/create_auto_backup_view.dart +++ b/lib/pages/settings_views/global_settings_view/stack_backup_views/create_auto_backup_view.dart @@ -141,7 +141,7 @@ class _EnableAutoBackupViewState extends ConsumerState { Navigator.of(context).pop(); if (savedPath != null) { - ref.read(prefsChangeNotifierProvider).autoBackupLocation = savedPath; + ref.read(prefsChangeNotifierProvider).autoBackupLocation = pathToSave; ref.read(prefsChangeNotifierProvider).lastAutoBackup = now; ref.read(prefsChangeNotifierProvider).isAutoBackupEnabled = true; diff --git a/lib/pages/settings_views/global_settings_view/stack_backup_views/helpers/restore_create_backup.dart b/lib/pages/settings_views/global_settings_view/stack_backup_views/helpers/restore_create_backup.dart index cb4ec42a6a..b131db8312 100644 --- a/lib/pages/settings_views/global_settings_view/stack_backup_views/helpers/restore_create_backup.dart +++ b/lib/pages/settings_views/global_settings_view/stack_backup_views/helpers/restore_create_backup.dart @@ -1028,7 +1028,7 @@ abstract class SWB { } } } else { - final Map preNodeMap = {}; + final Map> preNodeMap = {}; for (final nodeData in nodes) { preNodeMap[nodeData['id'] as String] = nodeData as Map; } @@ -1039,19 +1039,7 @@ abstract class SWB { // node existed before restore attempt // revert to pre restore node await nodeService.save( - node.copyWith( - host: nodeData['host'] as String, - port: nodeData['port'] as int, - name: nodeData['name'] as String, - useSSL: nodeData['useSSL'] == "false" ? false : true, - enabled: nodeData['enabled'] == "false" ? false : true, - coinName: nodeData['coinName'] as String, - loginName: nodeData['loginName'] as String?, - isFailover: nodeData['isFailover'] as bool, - isDown: nodeData['isDown'] as bool, - trusted: nodeData['trusted'] as bool?, - isPrimary: nodeData["isPrimary"] as bool? ?? false, - ), + NodeModel.fromStackBackup({...nodeData, 'id': node.id}), nodeData['password'] as String?, true, ); @@ -1258,25 +1246,10 @@ abstract class SWB { .toSet(); for (final node in nodes) { - final id = node['id'] as String; + final nodeData = Map.from(node as Map); await nodeService.save( - NodeModel( - host: node['host'] as String, - port: node['port'] as int, - name: node['name'] as String, - id: id, - useSSL: node['useSSL'] == "false" ? false : true, - enabled: node['enabled'] == "false" ? false : true, - coinName: node['coinName'] as String, - loginName: node['loginName'] as String?, - isFailover: node['isFailover'] as bool, - isDown: node['isDown'] as bool, - torEnabled: node['torEnabled'] as bool? ?? true, - clearnetEnabled: node['plainEnabled'] as bool? ?? true, - isPrimary: - node["isPrimary"] as bool? ?? primaryIds?.contains(id) ?? false, - ), - node["password"] as String?, + NodeModel.fromStackBackup(nodeData, legacyPrimaryNodeIds: primaryIds), + nodeData["password"] as String?, true, ); } diff --git a/lib/pages/shopinbit/shopinbit_payment_shared.dart b/lib/pages/shopinbit/shopinbit_payment_shared.dart index c5b41564f9..4552e56a98 100644 --- a/lib/pages/shopinbit/shopinbit_payment_shared.dart +++ b/lib/pages/shopinbit/shopinbit_payment_shared.dart @@ -1,4 +1,3 @@ -import 'package:decimal/decimal.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -74,16 +73,14 @@ ShopInBitPaymentTarget parseShopInBitPaymentTarget({ Amount? amount; if (amountStr != null && amountStr.isNotEmpty) { - try { - amount = Amount.fromDecimal( - Decimal.parse(amountStr), - fractionDigits: fractionDigits, - ); - } catch (e, s) { + amount = Amount.tryParseCanonicalAmount( + amountStr, + fractionDigits: fractionDigits, + truncateOverprecision: true, + ); + if (amount == null) { Logging.instance.e( "Failed to parse ShopInBit payment amount '$amountStr'", - error: e, - stackTrace: s, ); } } diff --git a/lib/pages/wallet_view/transaction_views/transaction_search_filter_view.dart b/lib/pages/wallet_view/transaction_views/transaction_search_filter_view.dart index b799d10eb9..28ce036984 100644 --- a/lib/pages/wallet_view/transaction_views/transaction_search_filter_view.dart +++ b/lib/pages/wallet_view/transaction_views/transaction_search_filter_view.dart @@ -8,7 +8,6 @@ * */ -import 'package:decimal/decimal.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -18,6 +17,7 @@ import '../../../providers/ui/transaction_filter_provider.dart'; import '../../../themes/stack_colors.dart'; import '../../../themes/theme_providers.dart'; import '../../../utilities/amount/amount.dart'; +import '../../../utilities/amount/amount_field_relocalization.dart'; import '../../../utilities/amount/amount_formatter.dart'; import '../../../utilities/amount/amount_input_formatter.dart'; import '../../../utilities/constants.dart'; @@ -36,6 +36,21 @@ import '../../../widgets/rounded_white_container.dart'; import '../../../widgets/stack_text_field.dart'; import '../../../widgets/textfield_icon_button.dart'; +({bool isValid, Amount? amount}) parseTransactionFilterAmountInput({ + required String text, + required String locale, + required AmountFormatter formatter, +}) { + final decimalSeparator = + Util.getSymbolsFor(locale: locale)?.DECIMAL_SEP ?? "."; + if (text.isEmpty || text == decimalSeparator) { + return (isValid: true, amount: null); + } + + final amount = formatter.tryParseEditable(text); + return (isValid: amount != null, amount: amount); +} + class TransactionSearchFilterView extends ConsumerStatefulWidget { const TransactionSearchFilterView({super.key, required this.coin}); @@ -77,7 +92,7 @@ class _TransactionSearchViewState ? "" : ref .read(pAmountFormatter(widget.coin)) - .format(filterState.amount!, withUnitName: false); + .formatEditable(filterState.amount!); _amountTextEditingController.text = amount; } @@ -100,6 +115,10 @@ class _TransactionSearchViewState @override Widget build(BuildContext context) { + listenForAmountRelocalization( + ref.listen, + controllers: [_amountTextEditingController], + ); if (Util.isDesktop) { return DesktopDialog( maxWidth: 576, @@ -414,6 +433,7 @@ class _TransactionSearchViewState ), inputFormatters: [ AmountInputFormatter( + controller: _amountTextEditingController, decimals: widget.coin.fractionDigits, unit: ref.watch(pAmountUnit(widget.coin)), locale: ref.watch( @@ -629,16 +649,14 @@ class _TransactionSearchViewState } Future _onApplyPressed() async { - final amountText = _amountTextEditingController.text; - Amount? amount; - if (amountText.isNotEmpty && !(amountText == "," || amountText == ".")) { - amount = amountText.contains(",") - ? Decimal.parse( - amountText.replaceFirst(",", "."), - ).toAmount(fractionDigits: widget.coin.fractionDigits) - : Decimal.parse( - amountText, - ).toAmount(fractionDigits: widget.coin.fractionDigits); + final locale = ref.read(localeServiceChangeNotifierProvider).locale; + final parsedAmount = parseTransactionFilterAmountInput( + text: _amountTextEditingController.text, + locale: locale, + formatter: ref.read(pAmountFormatter(widget.coin)), + ); + if (!parsedAmount.isValid) { + return; } final TransactionFilter filter = TransactionFilter( @@ -647,7 +665,7 @@ class _TransactionSearchViewState trade: _isActiveTradeCheckbox, from: _selectedFromDate, to: _selectedToDate, - amount: amount, + amount: parsedAmount.amount, keyword: _keywordTextEditingController.text, ); diff --git a/lib/pages_desktop_specific/coin_control/desktop_coin_control_use_dialog.dart b/lib/pages_desktop_specific/coin_control/desktop_coin_control_use_dialog.dart index d0959351ae..255c334b3b 100644 --- a/lib/pages_desktop_specific/coin_control/desktop_coin_control_use_dialog.dart +++ b/lib/pages_desktop_specific/coin_control/desktop_coin_control_use_dialog.dart @@ -18,6 +18,7 @@ import 'package:isar_community/isar.dart'; import '../../db/isar/main_db.dart'; import '../../models/input.dart'; import '../../models/isar/models/blockchain_data/utxo.dart'; +import '../../providers/global/locale_provider.dart'; import '../../themes/coin_icon_provider.dart'; import '../../themes/stack_colors.dart'; import '../../utilities/amount/amount.dart'; @@ -108,6 +109,10 @@ class _DesktopCoinControlUseDialogState Widget build(BuildContext context) { debugPrint("BUILD: $runtimeType"); + final locale = ref.watch( + localeServiceChangeNotifierProvider.select((value) => value.locale), + ); + if (_sort == CCSortDescriptor.address) { _list = null; _map = MainDB.instance.queryUTXOsGroupedByAddressSync( @@ -115,6 +120,7 @@ class _DesktopCoinControlUseDialogState filter: _filter, sort: _sort, searchTerm: _searchString, + locale: locale, cryptoCurrency: coin, ); } else { @@ -124,6 +130,7 @@ class _DesktopCoinControlUseDialogState filter: _filter, sort: _sort, searchTerm: _searchString, + locale: locale, cryptoCurrency: coin, ); } @@ -132,18 +139,16 @@ class _DesktopCoinControlUseDialogState .map((e) => e.value) .fold( Amount(rawValue: BigInt.zero, fractionDigits: coin.fractionDigits), - (value, element) => - value += Amount( - rawValue: BigInt.from(element), - fractionDigits: coin.fractionDigits, - ), + (value, element) => value += Amount( + rawValue: BigInt.from(element), + fractionDigits: coin.fractionDigits, + ), ); - final enableApply = - widget.amountToSend == null - ? selectedChanged(_selectedUTXOs) - : selectedChanged(_selectedUTXOs) && - widget.amountToSend! <= selectedSum; + final enableApply = widget.amountToSend == null + ? selectedChanged(_selectedUTXOs) + : selectedChanged(_selectedUTXOs) && + widget.amountToSend! <= selectedSum; return DesktopDialog( maxWidth: 700, @@ -163,10 +168,9 @@ class _DesktopCoinControlUseDialogState children: [ RoundedContainer( color: Colors.transparent, - borderColor: - Theme.of( - context, - ).extension()!.textFieldDefaultBG, + borderColor: Theme.of( + context, + ).extension()!.textFieldDefaultBG, child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ @@ -198,56 +202,55 @@ class _DesktopCoinControlUseDialogState _searchString = value; }); }, - style: STextStyles.desktopTextExtraSmall( - context, - ).copyWith( - color: - Theme.of(context) + style: STextStyles.desktopTextExtraSmall(context) + .copyWith( + color: Theme.of(context) .extension()! .textFieldActiveText, - height: 1.8, - ), - decoration: standardInputDecoration( - "Search...", - searchFieldFocusNode, - context, - desktopMed: true, - ).copyWith( - prefixIcon: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 18, - ), - child: SvgPicture.asset( - Assets.svg.search, - width: 20, - height: 20, + height: 1.8, ), - ), - suffixIcon: - _searchController.text.isNotEmpty + decoration: + standardInputDecoration( + "Search...", + searchFieldFocusNode, + context, + desktopMed: true, + ).copyWith( + prefixIcon: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 18, + ), + child: SvgPicture.asset( + Assets.svg.search, + width: 20, + height: 20, + ), + ), + suffixIcon: _searchController.text.isNotEmpty ? Padding( - padding: const EdgeInsets.only( - right: 0, - ), - child: UnconstrainedBox( - child: Row( - children: [ - TextFieldIconButton( - child: const XIcon(), - onTap: () async { - setState(() { - _searchController.text = ""; - _searchString = ""; - }); - }, - ), - ], + padding: const EdgeInsets.only( + right: 0, ), - ), - ) + child: UnconstrainedBox( + child: Row( + children: [ + TextFieldIconButton( + child: const XIcon(), + onTap: () async { + setState(() { + _searchController.text = + ""; + _searchString = ""; + }); + }, + ), + ], + ), + ), + ) : null, - ), + ), ), ), ), @@ -257,14 +260,12 @@ class _DesktopCoinControlUseDialogState width: 240, child: Toggle( isOn: _filter == CCFilter.frozen, - onColor: - Theme.of(context) - .extension()! - .rateTypeToggleDesktopColorOn, - offColor: - Theme.of(context) - .extension()! - .rateTypeToggleDesktopColorOff, + onColor: Theme.of(context) + .extension()! + .rateTypeToggleDesktopColorOn, + offColor: Theme.of(context) + .extension()! + .rateTypeToggleDesktopColorOff, onIcon: Assets.svg.coinControl.unBlocked, onText: "Available", offIcon: Assets.svg.coinControl.blocked, @@ -303,167 +304,151 @@ class _DesktopCoinControlUseDialogState ), const SizedBox(height: 16), Expanded( - child: - _list != null - ? ListView.separated( - shrinkWrap: true, - primary: false, - itemCount: _list!.length, - separatorBuilder: - (context, _) => const SizedBox(height: 10), - itemBuilder: (context, index) { - final utxo = - MainDB.instance.isar.utxos - .where() - .idEqualTo(_list![index]) - .findFirstSync()!; - final data = UtxoRowData(utxo.id, false); - data.selected = _selectedUTXOsData.contains( - data, - ); - - return UtxoRow( - key: Key( - "${utxo.walletId}_${utxo.id}_${utxo.isBlocked}", - ), - data: data, - compact: true, - walletId: widget.walletId, - onSelectionChanged: (value) { - setState(() { - if (data.selected) { - _selectedUTXOsData.add(value); - _selectedUTXOs.add(utxo); - } else { - _selectedUTXOsData.remove(value); - _selectedUTXOs.remove(utxo); - } - }); - }, - ); - }, - ) - : ListView.separated( - itemCount: _map!.entries.length, - separatorBuilder: - (context, _) => const SizedBox(height: 10), - itemBuilder: (context, index) { - final entry = _map!.entries.elementAt(index); - final _controller = RotateIconController(); + child: _list != null + ? ListView.separated( + shrinkWrap: true, + primary: false, + itemCount: _list!.length, + separatorBuilder: (context, _) => + const SizedBox(height: 10), + itemBuilder: (context, index) { + final utxo = MainDB.instance.isar.utxos + .where() + .idEqualTo(_list![index]) + .findFirstSync()!; + final data = UtxoRowData(utxo.id, false); + data.selected = _selectedUTXOsData.contains(data); - return Expandable2( - border: - Theme.of(context) - .extension()! - .backgroundAppBar, - background: - Theme.of( - context, - ).extension()!.popupBG, - animationDurationMultiplier: - 0.2 * entry.value.length, - onExpandWillChange: (state) { - if (state == Expandable2State.expanded) { - _controller.forward?.call(); + return UtxoRow( + key: Key( + "${utxo.walletId}_${utxo.id}_${utxo.isBlocked}", + ), + data: data, + compact: true, + walletId: widget.walletId, + onSelectionChanged: (value) { + setState(() { + if (data.selected) { + _selectedUTXOsData.add(value); + _selectedUTXOs.add(utxo); } else { - _controller.reverse?.call(); + _selectedUTXOsData.remove(value); + _selectedUTXOs.remove(utxo); } - }, - header: RoundedContainer( - padding: const EdgeInsets.all(20), - color: Colors.transparent, - child: Row( - children: [ - SvgPicture.file( - File( - ref.watch(coinIconProvider(coin)), - ), - width: 24, - height: 24, - ), - const SizedBox(width: 12), - Expanded( - flex: 3, - child: Text( - entry.key, - style: STextStyles.w600_14(context), - ), + }); + }, + ); + }, + ) + : ListView.separated( + itemCount: _map!.entries.length, + separatorBuilder: (context, _) => + const SizedBox(height: 10), + itemBuilder: (context, index) { + final entry = _map!.entries.elementAt(index); + final _controller = RotateIconController(); + + return Expandable2( + border: Theme.of( + context, + ).extension()!.backgroundAppBar, + background: Theme.of( + context, + ).extension()!.popupBG, + animationDurationMultiplier: + 0.2 * entry.value.length, + onExpandWillChange: (state) { + if (state == Expandable2State.expanded) { + _controller.forward?.call(); + } else { + _controller.reverse?.call(); + } + }, + header: RoundedContainer( + padding: const EdgeInsets.all(20), + color: Colors.transparent, + child: Row( + children: [ + SvgPicture.file( + File(ref.watch(coinIconProvider(coin))), + width: 24, + height: 24, + ), + const SizedBox(width: 12), + Expanded( + flex: 3, + child: Text( + entry.key, + style: STextStyles.w600_14(context), ), - Expanded( - child: Text( - "${entry.value.length} " - "output${entry.value.length > 1 ? "s" : ""}", - style: - STextStyles.desktopTextExtraExtraSmall( - context, - ), - ), + ), + Expanded( + child: Text( + "${entry.value.length} " + "output${entry.value.length > 1 ? "s" : ""}", + style: + STextStyles.desktopTextExtraExtraSmall( + context, + ), ), - RotateIcon( - animationDurationMultiplier: - 0.2 * entry.value.length, - icon: SvgPicture.asset( - Assets.svg.chevronDown, - width: 14, - color: - Theme.of(context) - .extension()! - .textSubtitle1, - ), - curve: Curves.easeInOut, - controller: _controller, + ), + RotateIcon( + animationDurationMultiplier: + 0.2 * entry.value.length, + icon: SvgPicture.asset( + Assets.svg.chevronDown, + width: 14, + color: Theme.of(context) + .extension()! + .textSubtitle1, ), - ], - ), + curve: Curves.easeInOut, + controller: _controller, + ), + ], ), - children: - entry.value.map((id) { - final utxo = - MainDB.instance.isar.utxos - .where() - .idEqualTo(id) - .findFirstSync()!; - final data = UtxoRowData( - utxo.id, - false, - ); - data.selected = _selectedUTXOsData - .contains(data); + ), + children: entry.value.map((id) { + final utxo = MainDB.instance.isar.utxos + .where() + .idEqualTo(id) + .findFirstSync()!; + final data = UtxoRowData(utxo.id, false); + data.selected = _selectedUTXOsData.contains( + data, + ); - return UtxoRow( - key: Key( - "${utxo.walletId}_${utxo.id}_${utxo.isBlocked}", - ), - data: data, - compact: true, - compactWithBorder: false, - raiseOnSelected: false, - walletId: widget.walletId, - onSelectionChanged: (value) { - setState(() { - if (data.selected) { - _selectedUTXOsData.add(value); - _selectedUTXOs.add(utxo); - } else { - _selectedUTXOsData.remove( - value, - ); - _selectedUTXOs.remove(utxo); - } - }); - }, - ); - }).toList(), - ); - }, - ), + return UtxoRow( + key: Key( + "${utxo.walletId}_${utxo.id}_${utxo.isBlocked}", + ), + data: data, + compact: true, + compactWithBorder: false, + raiseOnSelected: false, + walletId: widget.walletId, + onSelectionChanged: (value) { + setState(() { + if (data.selected) { + _selectedUTXOsData.add(value); + _selectedUTXOs.add(utxo); + } else { + _selectedUTXOsData.remove(value); + _selectedUTXOs.remove(utxo); + } + }); + }, + ); + }).toList(), + ); + }, + ), ), const SizedBox(height: 16), RoundedContainer( - color: - Theme.of( - context, - ).extension()!.textFieldDefaultBG, + color: Theme.of( + context, + ).extension()!.textFieldDefaultBG, padding: EdgeInsets.zero, child: ConditionalParent( condition: widget.amountToSend != null, @@ -474,10 +459,9 @@ class _DesktopCoinControlUseDialogState child, Container( height: 1.2, - color: - Theme.of( - context, - ).extension()!.popupBG, + color: Theme.of( + context, + ).extension()!.popupBG, ), Padding( padding: const EdgeInsets.all(16), @@ -491,10 +475,9 @@ class _DesktopCoinControlUseDialogState STextStyles.desktopTextExtraExtraSmall( context, ).copyWith( - color: - Theme.of(context) - .extension()! - .textDark, + color: Theme.of( + context, + ).extension()!.textDark, ), ), SelectableText( @@ -504,10 +487,9 @@ class _DesktopCoinControlUseDialogState STextStyles.desktopTextExtraExtraSmall( context, ).copyWith( - color: - Theme.of(context) - .extension()! - .textDark, + color: Theme.of( + context, + ).extension()!.textDark, ), ), ], @@ -523,33 +505,33 @@ class _DesktopCoinControlUseDialogState children: [ Text( "Selected amount", - style: STextStyles.desktopTextExtraExtraSmall( - context, - ).copyWith( - color: - Theme.of( + style: + STextStyles.desktopTextExtraExtraSmall( + context, + ).copyWith( + color: Theme.of( context, ).extension()!.textDark, - ), + ), ), SelectableText( "${selectedSum.decimal.toStringAsFixed(coin.fractionDigits)} ${coin.ticker}", - style: STextStyles.desktopTextExtraExtraSmall( - context, - ).copyWith( - color: - widget.amountToSend == null + style: + STextStyles.desktopTextExtraExtraSmall( + context, + ).copyWith( + color: widget.amountToSend == null ? Theme.of( - context, - ).extension()!.textDark + context, + ).extension()!.textDark : selectedSum < widget.amountToSend! ? Theme.of(context) - .extension()! - .accentColorRed + .extension()! + .accentColorRed : Theme.of(context) - .extension()! - .accentColorGreen, - ), + .extension()! + .accentColorGreen, + ), ), ], ), @@ -563,10 +545,9 @@ class _DesktopCoinControlUseDialogState child: SecondaryButton( enabled: _selectedUTXOsData.isNotEmpty, buttonHeight: ButtonHeight.l, - label: - _selectedUTXOsData.isEmpty - ? "Clear selection" - : "Clear selection (${_selectedUTXOsData.length})", + label: _selectedUTXOsData.isEmpty + ? "Clear selection" + : "Clear selection (${_selectedUTXOsData.length})", onPressed: () { setState(() { _selectedUTXOsData.clear(); diff --git a/lib/pages_desktop_specific/coin_control/desktop_coin_control_view.dart b/lib/pages_desktop_specific/coin_control/desktop_coin_control_view.dart index 3489f6cf8b..06c10a3f0d 100644 --- a/lib/pages_desktop_specific/coin_control/desktop_coin_control_view.dart +++ b/lib/pages_desktop_specific/coin_control/desktop_coin_control_view.dart @@ -17,6 +17,7 @@ import 'package:isar_community/isar.dart'; import '../../db/isar/main_db.dart'; import '../../models/isar/models/blockchain_data/utxo.dart'; +import '../../providers/global/locale_provider.dart'; import '../../themes/coin_icon_provider.dart'; import '../../themes/stack_colors.dart'; import '../../utilities/assets.dart'; @@ -84,6 +85,10 @@ class _DesktopCoinControlViewState Widget build(BuildContext context) { debugPrint("BUILD: $runtimeType"); + final locale = ref.watch( + localeServiceChangeNotifierProvider.select((value) => value.locale), + ); + if (_sort == CCSortDescriptor.address) { _list = null; _map = MainDB.instance.queryUTXOsGroupedByAddressSync( @@ -91,6 +96,7 @@ class _DesktopCoinControlViewState filter: _filter, sort: _sort, searchTerm: _searchString, + locale: locale, cryptoCurrency: coin, ); } else { @@ -100,6 +106,7 @@ class _DesktopCoinControlViewState filter: _filter, sort: _sort, searchTerm: _searchString, + locale: locale, cryptoCurrency: coin, ); } @@ -114,19 +121,17 @@ class _DesktopCoinControlViewState const SizedBox(width: 32), AppBarIconButton( size: 32, - color: - Theme.of( - context, - ).extension()!.textFieldDefaultBG, + color: Theme.of( + context, + ).extension()!.textFieldDefaultBG, shadows: const [], icon: SvgPicture.asset( Assets.svg.arrowLeft, width: 18, height: 18, - color: - Theme.of( - context, - ).extension()!.topNavIconPrimary, + color: Theme.of( + context, + ).extension()!.topNavIconPrimary, ), onPressed: Navigator.of(context).pop, ), @@ -135,8 +140,9 @@ class _DesktopCoinControlViewState Assets.svg.coinControl.gamePad, width: 32, height: 32, - color: - Theme.of(context).extension()!.textSubtitle1, + color: Theme.of( + context, + ).extension()!.textSubtitle1, ), const SizedBox(width: 12), Text("Coin control", style: STextStyles.desktopH3(context)), @@ -167,54 +173,52 @@ class _DesktopCoinControlViewState _searchString = value; }); }, - style: STextStyles.desktopTextExtraSmall( - context, - ).copyWith( - color: - Theme.of( + style: STextStyles.desktopTextExtraSmall(context) + .copyWith( + color: Theme.of( context, ).extension()!.textFieldActiveText, - height: 1.8, - ), - decoration: standardInputDecoration( - "Search...", - searchFieldFocusNode, - context, - desktopMed: true, - ).copyWith( - prefixIcon: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 18, + height: 1.8, ), - child: SvgPicture.asset( - Assets.svg.search, - width: 20, - height: 20, - ), - ), - suffixIcon: - _searchController.text.isNotEmpty + decoration: + standardInputDecoration( + "Search...", + searchFieldFocusNode, + context, + desktopMed: true, + ).copyWith( + prefixIcon: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 18, + ), + child: SvgPicture.asset( + Assets.svg.search, + width: 20, + height: 20, + ), + ), + suffixIcon: _searchController.text.isNotEmpty ? Padding( - padding: const EdgeInsets.only(right: 0), - child: UnconstrainedBox( - child: Row( - children: [ - TextFieldIconButton( - child: const XIcon(), - onTap: () async { - setState(() { - _searchController.text = ""; - _searchString = ""; - }); - }, - ), - ], + padding: const EdgeInsets.only(right: 0), + child: UnconstrainedBox( + child: Row( + children: [ + TextFieldIconButton( + child: const XIcon(), + onTap: () async { + setState(() { + _searchController.text = ""; + _searchString = ""; + }); + }, + ), + ], + ), ), - ), - ) + ) : null, - ), + ), ), ), ), @@ -238,10 +242,9 @@ class _DesktopCoinControlViewState key: Key("${_selectedUTXOs.length}"), selectedUTXOs: _selectedUTXOs, ), - crossFadeState: - _selectedUTXOs.isEmpty - ? CrossFadeState.showFirst - : CrossFadeState.showSecond, + crossFadeState: _selectedUTXOs.isEmpty + ? CrossFadeState.showFirst + : CrossFadeState.showSecond, duration: const Duration(milliseconds: 200), ), const SizedBox(width: 24), @@ -266,10 +269,9 @@ class _DesktopCoinControlViewState label: "Clear selection (${_selectedUTXOs.length})", onPressed: () => setState(() => _selectedUTXOs.clear()), ), - crossFadeState: - _selectedUTXOs.isEmpty - ? CrossFadeState.showFirst - : CrossFadeState.showSecond, + crossFadeState: _selectedUTXOs.isEmpty + ? CrossFadeState.showFirst + : CrossFadeState.showSecond, duration: const Duration(milliseconds: 200), ), ], @@ -278,140 +280,132 @@ class _DesktopCoinControlViewState Expanded( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 24), - child: - _list != null - ? ListView.separated( - itemCount: _list!.length, - separatorBuilder: - (context, _) => const SizedBox(height: 10), - itemBuilder: (context, index) { - final utxo = - MainDB.instance.isar.utxos - .where() - .idEqualTo(_list![index]) - .findFirstSync()!; - final data = UtxoRowData(utxo.id, false); - data.selected = _selectedUTXOs.contains(data); + child: _list != null + ? ListView.separated( + itemCount: _list!.length, + separatorBuilder: (context, _) => + const SizedBox(height: 10), + itemBuilder: (context, index) { + final utxo = MainDB.instance.isar.utxos + .where() + .idEqualTo(_list![index]) + .findFirstSync()!; + final data = UtxoRowData(utxo.id, false); + data.selected = _selectedUTXOs.contains(data); - return UtxoRow( - key: Key( - "${utxo.walletId}_${utxo.id}_${utxo.isBlocked}", - ), - data: data, - walletId: widget.walletId, - onSelectionChanged: (value) { - setState(() { - if (data.selected) { - _selectedUTXOs.add(value); - } else { - _selectedUTXOs.remove(value); - } - }); - }, - ); - }, - ) - : ListView.separated( - itemCount: _map!.entries.length, - separatorBuilder: - (context, _) => const SizedBox(height: 10), - itemBuilder: (context, index) { - final entry = _map!.entries.elementAt(index); - final _controller = RotateIconController(); - - return Expandable2( - border: - Theme.of( - context, - ).extension()!.backgroundAppBar, - background: - Theme.of( - context, - ).extension()!.popupBG, - animationDurationMultiplier: - 0.2 * entry.value.length, - onExpandWillChange: (state) { - if (state == Expandable2State.expanded) { - _controller.forward?.call(); + return UtxoRow( + key: Key( + "${utxo.walletId}_${utxo.id}_${utxo.isBlocked}", + ), + data: data, + walletId: widget.walletId, + onSelectionChanged: (value) { + setState(() { + if (data.selected) { + _selectedUTXOs.add(value); } else { - _controller.reverse?.call(); + _selectedUTXOs.remove(value); } - }, - header: RoundedContainer( - padding: const EdgeInsets.all(20), - color: Colors.transparent, - child: Row( - children: [ - SvgPicture.file( - File(ref.watch(coinIconProvider(coin))), - width: 24, - height: 24, - ), - const SizedBox(width: 12), - Expanded( - child: Text( - entry.key, - style: STextStyles.w600_14(context), - ), + }); + }, + ); + }, + ) + : ListView.separated( + itemCount: _map!.entries.length, + separatorBuilder: (context, _) => + const SizedBox(height: 10), + itemBuilder: (context, index) { + final entry = _map!.entries.elementAt(index); + final _controller = RotateIconController(); + + return Expandable2( + border: Theme.of( + context, + ).extension()!.backgroundAppBar, + background: Theme.of( + context, + ).extension()!.popupBG, + animationDurationMultiplier: 0.2 * entry.value.length, + onExpandWillChange: (state) { + if (state == Expandable2State.expanded) { + _controller.forward?.call(); + } else { + _controller.reverse?.call(); + } + }, + header: RoundedContainer( + padding: const EdgeInsets.all(20), + color: Colors.transparent, + child: Row( + children: [ + SvgPicture.file( + File(ref.watch(coinIconProvider(coin))), + width: 24, + height: 24, + ), + const SizedBox(width: 12), + Expanded( + child: Text( + entry.key, + style: STextStyles.w600_14(context), ), - Expanded( - child: Text( - "${entry.value.length} " - "output${entry.value.length > 1 ? "s" : ""}", - style: - STextStyles.desktopTextExtraExtraSmall( - context, - ), - ), + ), + Expanded( + child: Text( + "${entry.value.length} " + "output${entry.value.length > 1 ? "s" : ""}", + style: + STextStyles.desktopTextExtraExtraSmall( + context, + ), ), - RotateIcon( - animationDurationMultiplier: - 0.2 * entry.value.length, - icon: SvgPicture.asset( - Assets.svg.chevronDown, - width: 14, - color: - Theme.of(context) - .extension()! - .textSubtitle1, - ), - curve: Curves.easeInOut, - controller: _controller, + ), + RotateIcon( + animationDurationMultiplier: + 0.2 * entry.value.length, + icon: SvgPicture.asset( + Assets.svg.chevronDown, + width: 14, + color: Theme.of( + context, + ).extension()!.textSubtitle1, ), - ], - ), + curve: Curves.easeInOut, + controller: _controller, + ), + ], ), - children: - entry.value.map((id) { - final utxo = - MainDB.instance.isar.utxos - .where() - .idEqualTo(id) - .findFirstSync()!; - final data = UtxoRowData(utxo.id, false); - data.selected = _selectedUTXOs.contains(data); + ), + children: entry.value.map((id) { + final utxo = MainDB.instance.isar.utxos + .where() + .idEqualTo(id) + .findFirstSync()!; + final data = UtxoRowData(utxo.id, false); + data.selected = _selectedUTXOs.contains(data); - return UtxoRow( - key: Key( - "${utxo.walletId}_${utxo.id}_${utxo.isBlocked}", - ), - data: data, - walletId: widget.walletId, - raiseOnSelected: false, - onSelectionChanged: (value) { - setState(() { - if (data.selected) { - _selectedUTXOs.add(value); - } else { - _selectedUTXOs.remove(value); - } - }); - }, - ); - }).toList(), - ); - }, - ), + return UtxoRow( + key: Key( + "${utxo.walletId}_${utxo.id}_${utxo.isBlocked}", + ), + data: data, + walletId: widget.walletId, + raiseOnSelected: false, + onSelectionChanged: (value) { + setState(() { + if (data.selected) { + _selectedUTXOs.add(value); + } else { + _selectedUTXOs.remove(value); + } + }); + }, + ); + }).toList(), + ); + }, + ), ), ), ], diff --git a/lib/pages_desktop_specific/desktop_exchange/desktop_all_trades_view.dart b/lib/pages_desktop_specific/desktop_exchange/desktop_all_trades_view.dart index 61d763bf26..19985a9d3b 100644 --- a/lib/pages_desktop_specific/desktop_exchange/desktop_all_trades_view.dart +++ b/lib/pages_desktop_specific/desktop_exchange/desktop_all_trades_view.dart @@ -44,6 +44,11 @@ import '../../widgets/rounded_white_container.dart'; import '../../widgets/stack_text_field.dart'; import '../../widgets/textfield_icon_button.dart'; +Future loadAndPresentDesktopTradeDetails({ + required Future Function() load, + required void Function(T) present, +}) async => present(await load()); + class DesktopAllTradesView extends ConsumerStatefulWidget { const DesktopAllTradesView({super.key}); @@ -107,19 +112,17 @@ class _DesktopAllTradesViewState extends ConsumerState { const SizedBox(width: 32), AppBarIconButton( size: 32, - color: - Theme.of( - context, - ).extension()!.textFieldDefaultBG, + color: Theme.of( + context, + ).extension()!.textFieldDefaultBG, shadows: const [], icon: SvgPicture.asset( Assets.svg.arrowLeft, width: 18, height: 18, - color: - Theme.of( - context, - ).extension()!.topNavIconPrimary, + color: Theme.of( + context, + ).extension()!.topNavIconPrimary, ), onPressed: Navigator.of(context).pop, ), @@ -150,54 +153,52 @@ class _DesktopAllTradesViewState extends ConsumerState { _searchString = value; }); }, - style: STextStyles.desktopTextExtraSmall( - context, - ).copyWith( - color: - Theme.of( + style: STextStyles.desktopTextExtraSmall(context) + .copyWith( + color: Theme.of( context, ).extension()!.textFieldActiveText, - height: 1.8, - ), - decoration: standardInputDecoration( - "Search...", - searchFieldFocusNode, - context, - desktopMed: true, - ).copyWith( - prefixIcon: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 18, + height: 1.8, ), - child: SvgPicture.asset( - Assets.svg.search, - width: 20, - height: 20, - ), - ), - suffixIcon: - _searchController.text.isNotEmpty + decoration: + standardInputDecoration( + "Search...", + searchFieldFocusNode, + context, + desktopMed: true, + ).copyWith( + prefixIcon: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 18, + ), + child: SvgPicture.asset( + Assets.svg.search, + width: 20, + height: 20, + ), + ), + suffixIcon: _searchController.text.isNotEmpty ? Padding( - padding: const EdgeInsets.only(right: 0), - child: UnconstrainedBox( - child: Row( - children: [ - TextFieldIconButton( - child: const XIcon(), - onTap: () async { - setState(() { - _searchController.text = ""; - _searchString = ""; - }); - }, - ), - ], + padding: const EdgeInsets.only(right: 0), + child: UnconstrainedBox( + child: Row( + children: [ + TextFieldIconButton( + child: const XIcon(), + onTap: () async { + setState(() { + _searchController.text = ""; + _searchString = ""; + }); + }, + ), + ], + ), ), - ), - ) + ) : null, - ), + ), ), ), ), @@ -240,25 +241,22 @@ class _DesktopAllTradesViewState extends ConsumerState { child: ListView.separated( shrinkWrap: true, primary: false, - separatorBuilder: - (context, _) => Container( - height: 1, - color: - Theme.of(context) - .extension()! - .background, - ), + separatorBuilder: (context, _) => Container( + height: 1, + color: Theme.of( + context, + ).extension()!.background, + ), itemCount: month.item2.length, - itemBuilder: - (context, index) => Padding( - padding: const EdgeInsets.all(4), - child: DesktopTradeRowCard( - key: Key( - "transactionCard_key_${month.item2[index].tradeId}", - ), - tradeId: month.item2[index].tradeId, - ), + itemBuilder: (context, index) => Padding( + padding: const EdgeInsets.all(4), + child: DesktopTradeRowCard( + key: Key( + "transactionCard_key_${month.item2[index].tradeId}", ), + tradeId: month.item2[index].tradeId, + ), + ), ), ), ], @@ -341,8 +339,9 @@ class _DesktopTradeRowCardState extends ConsumerState { .read(tradeSentFromStackLookupProvider) .getWalletIdsForTradeId(tradeId); - final trade = - ref.watch(tradesServiceProvider.select((value) => value.get(tradeId)))!; + final trade = ref.watch( + tradesServiceProvider.select((value) => value.get(tradeId)), + )!; return Material( color: Theme.of(context).extension()!.popupBG, @@ -363,36 +362,18 @@ class _DesktopTradeRowCardState extends ConsumerState { //todo: check if print needed // debugPrint("name: ${manager.walletName}"); - final tx = - await MainDB.instance - .getTransactions(walletIds.first) - .filter() - .txidEqualTo(txid) - .findFirst(); - - if (mounted) { - await showDialog( - context: context, - builder: - (context) => DesktopDialog( - maxHeight: MediaQuery.of(context).size.height - 64, - maxWidth: 580, - child: TradeDetailsView( - tradeId: tradeId, - transactionIfSentFromStack: tx, - walletName: ref.read(pWalletName(walletIds.first)), - walletId: walletIds.first, - ), - ), - ); - } - - if (mounted) { - unawaited( - showDialog( - context: context, - builder: - (context) => Navigator( + await loadAndPresentDesktopTradeDetails( + load: () => MainDB.instance + .getTransactions(walletIds.first) + .filter() + .txidEqualTo(txid) + .findFirst(), + present: (tx) { + if (mounted) { + unawaited( + showDialog( + context: context, + builder: (context) => Navigator( initialRoute: TradeDetailsView.routeName, onGenerateRoute: RouteGenerator.generateRoute, onGenerateInitialRoutes: (_, __) { @@ -420,11 +401,10 @@ class _DesktopTradeRowCardState extends ConsumerState { ), ), DesktopDialogCloseButton( - onPressedOverride: - Navigator.of( - context, - rootNavigator: true, - ).pop, + onPressedOverride: Navigator.of( + context, + rootNavigator: true, + ).pop, ), ], ), @@ -452,70 +432,68 @@ class _DesktopTradeRowCardState extends ConsumerState { ]; }, ), - ), - ); - } + ), + ); + } + }, + ); } else { unawaited( showDialog( context: context, - builder: - (context) => Navigator( - initialRoute: TradeDetailsView.routeName, - onGenerateRoute: RouteGenerator.generateRoute, - onGenerateInitialRoutes: (_, __) { - return [ - FadePageRoute( - DesktopDialog( - maxHeight: null, - maxWidth: 580, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Padding( - padding: const EdgeInsets.only( - left: 32, - bottom: 16, - ), - child: Row( - mainAxisAlignment: - MainAxisAlignment.spaceBetween, - children: [ - Text( - "Trade details", - style: STextStyles.desktopH3(context), - ), - DesktopDialogCloseButton( - onPressedOverride: - Navigator.of( - context, - rootNavigator: true, - ).pop, - ), - ], + builder: (context) => Navigator( + initialRoute: TradeDetailsView.routeName, + onGenerateRoute: RouteGenerator.generateRoute, + onGenerateInitialRoutes: (_, __) { + return [ + FadePageRoute( + DesktopDialog( + maxHeight: null, + maxWidth: 580, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.only( + left: 32, + bottom: 16, + ), + child: Row( + mainAxisAlignment: + MainAxisAlignment.spaceBetween, + children: [ + Text( + "Trade details", + style: STextStyles.desktopH3(context), ), - ), - Flexible( - child: SingleChildScrollView( - primary: false, - child: TradeDetailsView( - tradeId: tradeId, - transactionIfSentFromStack: null, - walletName: null, - walletId: walletIds?.first, - ), + DesktopDialogCloseButton( + onPressedOverride: Navigator.of( + context, + rootNavigator: true, + ).pop, ), + ], + ), + ), + Flexible( + child: SingleChildScrollView( + primary: false, + child: TradeDetailsView( + tradeId: tradeId, + transactionIfSentFromStack: null, + walletName: null, + walletId: walletIds?.first, ), - ], + ), ), - ), - const RouteSettings( - name: TradeDetailsView.routeName, - ), + ], ), - ]; - }, - ), + ), + const RouteSettings(name: TradeDetailsView.routeName), + ), + ]; + }, + ), ), ); } @@ -547,12 +525,14 @@ class _DesktopTradeRowCardState extends ConsumerState { Expanded( flex: 3, child: Text( - "${trade.payInCurrency.toUpperCase()} → ${trade.payOutCurrency.toUpperCase()}", - style: STextStyles.desktopTextExtraExtraSmall( - context, - ).copyWith( - color: Theme.of(context).extension()!.textDark, - ), + "${trade.payInCurrency.toUpperCase()} " + "→ ${trade.payOutCurrency.toUpperCase()}", + style: STextStyles.desktopTextExtraExtraSmall(context) + .copyWith( + color: Theme.of( + context, + ).extension()!.textDark, + ), ), ), Expanded( @@ -568,11 +548,12 @@ class _DesktopTradeRowCardState extends ConsumerState { flex: 6, child: Text( "-${Decimal.tryParse(trade.payInAmount)?.toStringAsFixed(8) ?? "..."} ${trade.payInCurrency.toUpperCase()}", - style: STextStyles.desktopTextExtraExtraSmall( - context, - ).copyWith( - color: Theme.of(context).extension()!.textDark, - ), + style: STextStyles.desktopTextExtraExtraSmall(context) + .copyWith( + color: Theme.of( + context, + ).extension()!.textDark, + ), ), ), Expanded( @@ -586,8 +567,9 @@ class _DesktopTradeRowCardState extends ConsumerState { Assets.svg.circleInfo, width: 20, height: 20, - color: - Theme.of(context).extension()!.textSubtitle2, + color: Theme.of( + context, + ).extension()!.textSubtitle2, ), ], ), diff --git a/lib/pages_desktop_specific/desktop_exchange/exchange_steps/step_scaffold.dart b/lib/pages_desktop_specific/desktop_exchange/exchange_steps/step_scaffold.dart index 27d0b68a64..a0c7594875 100644 --- a/lib/pages_desktop_specific/desktop_exchange/exchange_steps/step_scaffold.dart +++ b/lib/pages_desktop_specific/desktop_exchange/exchange_steps/step_scaffold.dart @@ -10,7 +10,6 @@ import 'dart:async'; -import 'package:decimal/decimal.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -113,9 +112,10 @@ class _StepScaffoldState extends ConsumerState { ? ref.read(desktopExchangeModelProvider)!.receiveAmount : ref.read(desktopExchangeModelProvider)!.sendAmount, addressTo: ref.read(desktopExchangeModelProvider)!.recipientAddress!, - extraId: null, + extraId: ref.read(desktopExchangeModelProvider)!.extraId, addressRefund: ref.read(desktopExchangeModelProvider)!.refundAddress!, - refundExtraId: "", + refundExtraId: + ref.read(desktopExchangeModelProvider)!.refundExtraId ?? "", estimate: ref.read(desktopExchangeModelProvider)!.estimate, reversed: ref.read(desktopExchangeModelProvider)!.reversed, ); @@ -210,14 +210,27 @@ class _StepScaffoldState extends ConsumerState { } void sendFromStack() { - final trade = ref.read(desktopExchangeModelProvider)!.trade!; + final model = ref.read(desktopExchangeModelProvider)!; + final trade = model.trade!; final address = trade.payInAddress; final coin = AppConfig.getCryptoCurrencyForTicker(trade.payInCurrency) ?? AppConfig.getCryptoCurrencyByPrettyName(trade.payInCurrency); - final amount = Decimal.parse( - trade.payInAmount, - ).toAmount(fractionDigits: coin.fractionDigits); + final payInDecimal = model.payInDecimal; + if (payInDecimal == null) { + showDialog( + context: context, + barrierDismissible: true, + builder: (_) => SimpleDesktopDialog( + title: "Invalid trade amount", + message: + "The exchange returned an invalid pay-in amount:" + " \"${trade.payInAmount}\"", + ), + ); + return; + } + final amount = payInDecimal.toAmount(fractionDigits: coin.fractionDigits); showDialog( context: context, @@ -395,7 +408,7 @@ class _StepScaffoldState extends ConsumerState { crossAxisAlignment: CrossAxisAlignment.center, children: [ Text( - "Send ${ref.watch(desktopExchangeModelProvider.select((value) => value!.sendAmount.toStringAsFixed(8)))} ${ref.watch(desktopExchangeModelProvider.select((value) => value!.sendTicker))} to this address", + "Send ${ref.watch(desktopExchangeModelProvider.select((value) => value!.payInAmount))} ${ref.watch(desktopExchangeModelProvider.select((value) => value!.sendTicker))} to this address", style: STextStyles.desktopH3(context), ), const SizedBox(height: 48), diff --git a/lib/pages_desktop_specific/desktop_exchange/exchange_steps/subwidgets/desktop_step_2.dart b/lib/pages_desktop_specific/desktop_exchange/exchange_steps/subwidgets/desktop_step_2.dart index 46038a58d6..41e4da8625 100644 --- a/lib/pages_desktop_specific/desktop_exchange/exchange_steps/subwidgets/desktop_step_2.dart +++ b/lib/pages_desktop_specific/desktop_exchange/exchange_steps/subwidgets/desktop_step_2.dart @@ -17,8 +17,10 @@ import '../../../../app_config.dart'; import '../../../../models/contact_address_entry.dart'; import '../../../../providers/providers.dart'; import '../../../../themes/stack_colors.dart'; +import '../../../../utilities/address_utils.dart'; import '../../../../utilities/clipboard_interface.dart'; import '../../../../utilities/constants.dart'; +import '../../../../utilities/extra_id_currency_support.dart'; import '../../../../utilities/logger.dart'; import '../../../../utilities/text_styles.dart'; import '../../../../widgets/custom_buttons/blue_text_button.dart'; @@ -53,9 +55,47 @@ class _DesktopStep2State extends ConsumerState { late final TextEditingController _toController; late final TextEditingController _refundController; + late final TextEditingController _toMemoController; + late final TextEditingController _refundMemoController; late final FocusNode _toFocusNode; late final FocusNode _refundFocusNode; + late final FocusNode _toMemoFocusNode; + late final FocusNode _refundMemoFocusNode; + + bool get _showRecipientMemo => + ref.read(efExchangeProvider).supportsExtraId && + ExtraIdCurrencySupport.mayRequire( + ref.read(desktopExchangeModelProvider)!.receiveTicker, + ); + + bool get _showRefundMemo => + ref.read(efExchangeProvider).supportsExtraId && + ExtraIdCurrencySupport.mayRequire( + ref.read(desktopExchangeModelProvider)!.sendTicker, + ); + + void _setRecipientMemo(String? memo) { + // A null memo means the selected address source did not provide one. In + // that case keep any memo the user already entered instead of wiping it. + if (memo == null) return; + final value = _showRecipientMemo ? memo : ""; + _toMemoController.text = value; + ref.read(desktopExchangeModelProvider)!.extraId = value.isEmpty + ? null + : value; + } + + void _setRefundMemo(String? memo) { + // A null memo means the selected address source did not provide one. In + // that case keep any memo the user already entered instead of wiping it. + if (memo == null) return; + final value = _showRefundMemo ? memo : ""; + _refundMemoController.text = value; + ref.read(desktopExchangeModelProvider)!.refundExtraId = value.isEmpty + ? null + : value; + } void selectRecipientAddressFromStack() async { try { @@ -211,9 +251,17 @@ class _DesktopStep2State extends ConsumerState { _toController = TextEditingController(); _refundController = TextEditingController(); + _toMemoController = TextEditingController( + text: ref.read(desktopExchangeModelProvider)!.extraId ?? "", + ); + _refundMemoController = TextEditingController( + text: ref.read(desktopExchangeModelProvider)!.refundExtraId ?? "", + ); _toFocusNode = FocusNode(); _refundFocusNode = FocusNode(); + _toMemoFocusNode = FocusNode(); + _refundMemoFocusNode = FocusNode(); doesRefundAddress = ref.read(efExchangeProvider).supportsRefundAddress; @@ -262,9 +310,13 @@ class _DesktopStep2State extends ConsumerState { void dispose() { _toController.dispose(); _refundController.dispose(); + _toMemoController.dispose(); + _refundMemoController.dispose(); _toFocusNode.dispose(); _refundFocusNode.dispose(); + _toMemoFocusNode.dispose(); + _refundMemoFocusNode.dispose(); super.dispose(); } @@ -384,7 +436,18 @@ class _DesktopStep2State extends ConsumerState { if (data?.text != null && data!.text!.isNotEmpty) { final content = data.text!.trim(); - _toController.text = content; + final paymentData = + AddressUtils.parsePaymentUri( + content, + logging: Logging.instance, + ); + if (paymentData != null) { + _toController.text = + paymentData.address; + _setRecipientMemo(paymentData.memo); + } else { + _toController.text = content; + } ref .read(desktopExchangeModelProvider)! .recipientAddress = _toController @@ -416,6 +479,42 @@ class _DesktopStep2State extends ConsumerState { ), ), ), + if (_showRecipientMemo) const SizedBox(height: 10), + if (_showRecipientMemo) + Text( + "Memo or destination tag", + style: STextStyles.desktopTextExtraExtraSmall(context).copyWith( + color: Theme.of( + context, + ).extension()!.textFieldActiveSearchIconRight, + ), + ), + if (_showRecipientMemo) const SizedBox(height: 10), + if (_showRecipientMemo) + ClipRRect( + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + child: TextField( + key: const Key("recipientExchangeStep2ViewMemoFieldKey"), + controller: _toMemoController, + focusNode: _toMemoFocusNode, + autocorrect: false, + enableSuggestions: false, + style: STextStyles.field(context), + onChanged: (value) { + ref.read(desktopExchangeModelProvider)!.extraId = value.isEmpty + ? null + : value; + }, + decoration: standardInputDecoration( + "Enter the memo or tag required by the payout address, if any", + _toMemoFocusNode, + context, + desktopMed: true, + ), + ), + ), const SizedBox(height: 10), RoundedWhiteContainer( borderColor: Theme.of(context).extension()!.background, @@ -528,7 +627,18 @@ class _DesktopStep2State extends ConsumerState { data!.text!.isNotEmpty) { final content = data.text!.trim(); - _refundController.text = content; + final paymentData = + AddressUtils.parsePaymentUri( + content, + logging: Logging.instance, + ); + if (paymentData != null) { + _refundController.text = + paymentData.address; + _setRefundMemo(paymentData.memo); + } else { + _refundController.text = content; + } ref .read(desktopExchangeModelProvider)! .refundAddress = _refundController @@ -561,6 +671,41 @@ class _DesktopStep2State extends ConsumerState { ), ), ), + if (doesRefundAddress && _showRefundMemo) const SizedBox(height: 10), + if (doesRefundAddress && _showRefundMemo) + Text( + "Refund memo or destination tag", + style: STextStyles.desktopTextExtraExtraSmall(context).copyWith( + color: Theme.of( + context, + ).extension()!.textFieldActiveSearchIconRight, + ), + ), + if (doesRefundAddress && _showRefundMemo) const SizedBox(height: 10), + if (doesRefundAddress && _showRefundMemo) + ClipRRect( + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + child: TextField( + key: const Key("refundExchangeStep2ViewMemoFieldKey"), + controller: _refundMemoController, + focusNode: _refundMemoFocusNode, + autocorrect: false, + enableSuggestions: false, + style: STextStyles.field(context), + onChanged: (value) { + ref.read(desktopExchangeModelProvider)!.refundExtraId = + value.isEmpty ? null : value; + }, + decoration: standardInputDecoration( + "Enter the memo or tag required by the refund address, if any", + _refundMemoFocusNode, + context, + desktopMed: true, + ), + ), + ), if (doesRefundAddress) const SizedBox(height: 10), if (doesRefundAddress) RoundedWhiteContainer( diff --git a/lib/pages_desktop_specific/desktop_exchange/exchange_steps/subwidgets/desktop_step_3.dart b/lib/pages_desktop_specific/desktop_exchange/exchange_steps/subwidgets/desktop_step_3.dart index 98c4daaf53..1d2b82467f 100644 --- a/lib/pages_desktop_specific/desktop_exchange/exchange_steps/subwidgets/desktop_step_3.dart +++ b/lib/pages_desktop_specific/desktop_exchange/exchange_steps/subwidgets/desktop_step_3.dart @@ -20,9 +20,7 @@ import '../step_scaffold.dart'; import 'desktop_step_item.dart'; class DesktopStep3 extends ConsumerStatefulWidget { - const DesktopStep3({ - super.key, - }); + const DesktopStep3({super.key}); @override ConsumerState createState() => _DesktopStep3State(); @@ -37,9 +35,7 @@ class _DesktopStep3State extends ConsumerState { "Confirm exchange details", style: STextStyles.desktopTextMedium(context), ), - const SizedBox( - height: 20, - ), + const SizedBox(height: 20), RoundedWhiteContainer( borderColor: Theme.of(context).extension()!.background, padding: const EdgeInsets.all(0), @@ -72,16 +68,19 @@ class _DesktopStep3State extends ConsumerState { color: Theme.of(context).extension()!.background, ), DesktopStepItem( - label: ref.watch( - desktopExchangeModelProvider - .select((value) => value!.rateType), + label: + ref.watch( + desktopExchangeModelProvider.select( + (value) => value!.rateType, + ), ) == ExchangeRateType.estimated ? "Estimated rate" : "Fixed rate", value: ref.watch( - desktopExchangeModelProvider - .select((value) => value!.rateInfo), + desktopExchangeModelProvider.select( + (value) => value!.rateInfo, + ), ), ), Container( @@ -92,12 +91,37 @@ class _DesktopStep3State extends ConsumerState { vertical: true, label: "Recipient ${ref.watch(desktopExchangeModelProvider.select((value) => value!.receiveTicker.toUpperCase()))} address", - value: ref.watch( - desktopExchangeModelProvider - .select((value) => value!.recipientAddress), + value: + ref.watch( + desktopExchangeModelProvider.select( + (value) => value!.recipientAddress, + ), ) ?? "Error", ), + if (ref.watch( + desktopExchangeModelProvider.select( + (value) => value!.extraId?.isNotEmpty == true, + ), + )) + Container( + height: 1, + color: Theme.of(context).extension()!.background, + ), + if (ref.watch( + desktopExchangeModelProvider.select( + (value) => value!.extraId?.isNotEmpty == true, + ), + )) + DesktopStepItem( + vertical: true, + label: "Recipient memo or tag", + value: ref.watch( + desktopExchangeModelProvider.select( + (value) => value!.extraId!, + ), + ), + ), if (ref.watch(efExchangeProvider).supportsRefundAddress) Container( height: 1, @@ -108,12 +132,39 @@ class _DesktopStep3State extends ConsumerState { vertical: true, label: "Refund ${ref.watch(desktopExchangeModelProvider.select((value) => value!.sendTicker.toUpperCase()))} address", - value: ref.watch( - desktopExchangeModelProvider - .select((value) => value!.refundAddress), + value: + ref.watch( + desktopExchangeModelProvider.select( + (value) => value!.refundAddress, + ), ) ?? "Error", ), + if (ref.watch(efExchangeProvider).supportsRefundAddress && + ref.watch( + desktopExchangeModelProvider.select( + (value) => value!.refundExtraId?.isNotEmpty == true, + ), + )) + Container( + height: 1, + color: Theme.of(context).extension()!.background, + ), + if (ref.watch(efExchangeProvider).supportsRefundAddress && + ref.watch( + desktopExchangeModelProvider.select( + (value) => value!.refundExtraId?.isNotEmpty == true, + ), + )) + DesktopStepItem( + vertical: true, + label: "Refund memo or tag", + value: ref.watch( + desktopExchangeModelProvider.select( + (value) => value!.refundExtraId!, + ), + ), + ), ], ), ), diff --git a/lib/pages_desktop_specific/desktop_exchange/exchange_steps/subwidgets/desktop_step_4.dart b/lib/pages_desktop_specific/desktop_exchange/exchange_steps/subwidgets/desktop_step_4.dart index 6e18c5086b..12cad5e3a5 100644 --- a/lib/pages_desktop_specific/desktop_exchange/exchange_steps/subwidgets/desktop_step_4.dart +++ b/lib/pages_desktop_specific/desktop_exchange/exchange_steps/subwidgets/desktop_step_4.dart @@ -109,23 +109,21 @@ class _DesktopStep4State extends ConsumerState { child: RichText( text: TextSpan( text: - "You must send at least ${ref.watch(desktopExchangeModelProvider.select((value) => value!.sendAmount.toString()))} ${ref.watch(desktopExchangeModelProvider.select((value) => value!.sendTicker))}. ", + "You must send at least ${ref.watch(desktopExchangeModelProvider.select((value) => value!.payInAmount))} ${ref.watch(desktopExchangeModelProvider.select((value) => value!.sendTicker))}. ", style: STextStyles.label700(context).copyWith( - color: - Theme.of( - context, - ).extension()!.warningForeground, + color: Theme.of( + context, + ).extension()!.warningForeground, fontSize: 14, ), children: [ TextSpan( text: - "If you send less than ${ref.watch(desktopExchangeModelProvider.select((value) => value!.sendAmount.toString()))} ${ref.watch(desktopExchangeModelProvider.select((value) => value!.sendTicker))}, your transaction may not be converted and it may not be refunded.", + "If you send less than ${ref.watch(desktopExchangeModelProvider.select((value) => value!.payInAmount))} ${ref.watch(desktopExchangeModelProvider.select((value) => value!.sendTicker))}, your transaction may not be converted and it may not be refunded.", style: STextStyles.label(context).copyWith( - color: - Theme.of( - context, - ).extension()!.warningForeground, + color: Theme.of( + context, + ).extension()!.warningForeground, fontSize: 14, ), ), @@ -186,7 +184,7 @@ class _DesktopStep4State extends ConsumerState { DesktopStepItem( label: "Amount", value: - "${ref.watch(desktopExchangeModelProvider.select((value) => value!.sendAmount.toStringAsFixed(8)))} ${ref.watch(desktopExchangeModelProvider.select((value) => value!.sendTicker.toUpperCase()))}", + "${ref.watch(desktopExchangeModelProvider.select((value) => value!.payInAmount))} ${ref.watch(desktopExchangeModelProvider.select((value) => value!.sendTicker.toUpperCase()))}", ), Container( height: 1, @@ -217,13 +215,12 @@ class _DesktopStep4State extends ConsumerState { ), Text( _statusString, - style: STextStyles.desktopTextExtraExtraSmall( - context, - ).copyWith( - color: Theme.of(context) - .extension()! - .colorForStatus(_statusString), - ), + style: STextStyles.desktopTextExtraExtraSmall(context) + .copyWith( + color: Theme.of(context) + .extension()! + .colorForStatus(_statusString), + ), ), ], ), diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send.dart index 5060d2bdb0..259ff8efc9 100644 --- a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send.dart +++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send.dart @@ -38,12 +38,15 @@ import '../../../../services/spark_names_service.dart'; import '../../../../themes/stack_colors.dart'; import '../../../../utilities/address_utils.dart'; import '../../../../utilities/amount/amount.dart'; +import '../../../../utilities/amount/amount_field_relocalization.dart'; import '../../../../utilities/amount/amount_formatter.dart'; import '../../../../utilities/amount/amount_input_formatter.dart'; import '../../../../utilities/amount/amount_unit.dart'; import '../../../../utilities/assets.dart'; import '../../../../utilities/clipboard_interface.dart'; import '../../../../utilities/constants.dart'; +import '../../../../utilities/enums/fee_rate_type_enum.dart'; +import '../../../../utilities/integer_input.dart'; import '../../../../utilities/logger.dart'; import '../../../../utilities/prefs.dart'; import '../../../../utilities/show_loading.dart'; @@ -140,9 +143,13 @@ class _DesktopSendState extends ConsumerState { bool get isPaynymSend => widget.accountLite != null; - bool isCustomFee = false; + ({bool isValid, int? value}) get _nonceInput => + parseOptionalIntegerInput(nonceController.text, minimum: 0); + + bool get _nonceIsValid => _nonceInput.isValid; + int customFeeRate = 1; - EthEIP1559Fee? ethFee; + final _ethFee = ValueNotifier(null); Future scanWebcam() async { try { @@ -424,6 +431,10 @@ class _DesktopSendState extends ConsumerState { } Future previewSend() async { + final nonceInput = _nonceInput; + if (!nonceInput.isValid) return; + final nonce = nonceInput.value; + final wallet = ref.read(pWallets).getWallet(walletId); // Handle MWC slatepack transactions directly. @@ -580,11 +591,11 @@ class _DesktopSendState extends ConsumerState { TxData txData; Future txDataFuture; + final feeRateType = ref.read(feeRateTypeDesktopStateProvider); + final satsPerVByte = feeRateType.customSatsPerVByte(customFeeRate); if (isPaynymSend) { final paynymWallet = wallet as PaynymInterface; - - final feeRate = ref.read(feeRateTypeDesktopStateProvider); txDataFuture = paynymWallet.preparePaymentCodeSend( txData: TxData( paynymAccountLite: widget.accountLite!, @@ -596,8 +607,8 @@ class _DesktopSendState extends ConsumerState { addressType: AddressType.unknown, ), ], - satsPerVByte: isCustomFee ? customFeeRate : null, - feeRateType: feeRate, + satsPerVByte: satsPerVByte, + feeRateType: feeRateType, utxos: (wallet is CoinControlInterface && wallet is! SalviumWallet && @@ -621,8 +632,8 @@ class _DesktopSendState extends ConsumerState { isChange: false, ), ], - feeRateType: ref.read(feeRateTypeDesktopStateProvider), - satsPerVByte: isCustomFee ? customFeeRate : null, + feeRateType: feeRateType, + satsPerVByte: satsPerVByte, utxos: (coinControlEnabled && ref.read(pDesktopUseUTXOs).isNotEmpty) @@ -643,8 +654,8 @@ class _DesktopSendState extends ConsumerState { )!, ), ], - feeRateType: ref.read(feeRateTypeDesktopStateProvider), - satsPerVByte: isCustomFee ? customFeeRate : null, + feeRateType: feeRateType, + satsPerVByte: satsPerVByte, utxos: (coinControlEnabled && ref.read(pDesktopUseUTXOs).isNotEmpty) @@ -698,8 +709,8 @@ class _DesktopSendState extends ConsumerState { addressType: wallet.cryptoCurrency.getAddressType(_address!)!, ), ], - feeRateType: ref.read(feeRateTypeDesktopStateProvider), - satsPerVByte: isCustomFee ? customFeeRate : null, + feeRateType: feeRateType, + satsPerVByte: satsPerVByte, // these will need to be mweb utxos // utxos: // (wallet is CoinControlInterface && @@ -722,11 +733,9 @@ class _DesktopSendState extends ConsumerState { ), ], memo: memo, - feeRateType: ref.read(feeRateTypeDesktopStateProvider), - satsPerVByte: isCustomFee ? customFeeRate : null, - nonce: wallet.cryptoCurrency is Ethereum - ? int.tryParse(nonceController.text) - : null, + feeRateType: feeRateType, + satsPerVByte: satsPerVByte, + nonce: wallet.cryptoCurrency is Ethereum ? nonce : null, utxos: (wallet is CoinControlInterface && wallet is! SalviumWallet && @@ -734,7 +743,7 @@ class _DesktopSendState extends ConsumerState { ref.read(pDesktopUseUTXOs).isNotEmpty) ? ref.read(pDesktopUseUTXOs) : null, - ethEIP1559Fee: ethFee, + ethEIP1559Fee: _ethFee.value, ), ); } @@ -857,10 +866,16 @@ class _DesktopSendState extends ConsumerState { nonceController.text = ""; _address = ""; _addressToggleFlag = false; + _syncFeeAmount(null); _setOpReturnData(null); setState(() {}); } + void _syncFeeAmount(Amount? amount) { + ref.read(sendAmountProvider.notifier).state = + amount ?? Amount.zeroWith(fractionDigits: coin.fractionDigits); + } + void _setOpReturnData(String? data) { if (!mounted) { return; @@ -872,7 +887,7 @@ class _DesktopSendState extends ConsumerState { if (!_cryptoAmountChangeLock) { final cryptoAmount = ref .read(pAmountFormatter(coin)) - .tryParse(cryptoAmountController.text); + .tryParseEditable(cryptoAmountController.text); final Amount? amount; if (cryptoAmount != null) { amount = cryptoAmount; @@ -888,11 +903,13 @@ class _DesktopSendState extends ConsumerState { ?.value; if (price != null && price > Decimal.zero) { - final String fiatAmountString = (amount.decimal * price) - .toAmount(fractionDigits: 2) - .fiatString( - locale: ref.read(localeServiceChangeNotifierProvider).locale, - ); + final fiatAmount = (amount.decimal * price).toAmount( + fractionDigits: 2, + ); + final fiatAmountString = Amount.formatEditableDecimal( + fiatAmount.decimal, + locale: ref.read(localeServiceChangeNotifierProvider).locale, + ); baseAmountController.text = fiatAmountString; } @@ -999,13 +1016,23 @@ class _DesktopSendState extends ConsumerState { // autofill amount field if (paymentData.amount != null) { - final amount = Decimal.parse( + final amount = Amount.tryParseCanonicalAmount( paymentData.amount!, - ).toAmount(fractionDigits: coin.fractionDigits); - cryptoAmountController.text = ref - .read(pAmountFormatter(coin)) - .format(amount, withUnitName: false); - ref.read(pSendAmount.notifier).state = amount; + fractionDigits: coin.fractionDigits, + truncateOverprecision: true, + ); + if (amount != null) { + cryptoAmountController.text = ref + .read(pAmountFormatter(coin)) + .formatEditable(amount); + ref.read(pSendAmount.notifier).state = amount; + _syncFeeAmount(amount); + } else { + cryptoAmountController.clear(); + _cachedAmountToSend = null; + ref.read(pSendAmount.notifier).state = null; + _syncFeeAmount(null); + } } // Trigger validation after pasting. @@ -1149,13 +1176,14 @@ class _DesktopSendState extends ConsumerState { final amountString = ref .read(pAmountFormatter(coin)) - .format(amount, withUnitName: false); + .formatEditable(amount); _cryptoAmountChangeLock = true; cryptoAmountController.text = amountString; _cryptoAmountChangeLock = false; } else { amount = Decimal.zero.toAmount(fractionDigits: coin.fractionDigits); + _cachedAmountToSend = null; _cryptoAmountChangeLock = true; cryptoAmountController.text = ""; _cryptoAmountChangeLock = false; @@ -1203,7 +1231,8 @@ class _DesktopSendState extends ConsumerState { cryptoAmountController.text = ref .read(pAmountFormatter(coin)) - .format(amount, withUnitName: false); + .formatEditable(amount); + _syncFeeAmount(amount); } void _showDesktopCoinControl() async { @@ -1251,9 +1280,8 @@ class _DesktopSendState extends ConsumerState { _cryptoAmountChangeLock = true; cryptoAmountController.text = ref .read(pAmountFormatter(coin)) - .format( + .formatEditable( _data.amount!.toAmount(fractionDigits: coin.fractionDigits), - withUnitName: false, ); _cryptoAmountChangeLock = false; } @@ -1264,6 +1292,7 @@ class _DesktopSendState extends ConsumerState { WidgetsBinding.instance.addPostFrameCallback((_) { if (hasAmount) { _cryptoAmountChanged(); + _syncFeeAmount(ref.read(pSendAmount)); } _setValidAddressProviders(_address); }); @@ -1304,6 +1333,7 @@ class _DesktopSendState extends ConsumerState { @override void dispose() { cryptoAmountController.removeListener(onCryptoAmountChanged); + _ethFee.dispose(); sendToController.dispose(); cryptoAmountController.dispose(); @@ -1325,6 +1355,16 @@ class _DesktopSendState extends ConsumerState { final String locale = ref.watch( localeServiceChangeNotifierProvider.select((value) => value.locale), ); + listenForAmountRelocalization( + ref.listen, + controllers: [cryptoAmountController, baseAmountController], + onRelocalized: _cryptoAmountChanged, + ); + final isCustomFee = ref.watch(feeRateTypeDesktopStateProvider).isCustom; + // ethFee is checked in the ValueListenableBuilder around the preview + // button so fee keystrokes don't rebuild this whole view. + final previewEnabled = ref.watch(pPreviewTxButtonEnabled(coin)); + final needsEthFee = coin is Ethereum && isCustomFee; // add listener for epic cash to strip http:// and https:// prefixes if the address also ocntains an @ symbol (indicating an epicbox address) if (coin is Epiccash) { @@ -1616,6 +1656,7 @@ class _DesktopSendState extends ConsumerState { textAlign: TextAlign.right, inputFormatters: [ AmountInputFormatter( + controller: cryptoAmountController, decimals: coin.fractionDigits, unit: ref.watch(pAmountUnit(coin)), locale: locale, @@ -1675,7 +1716,11 @@ class _DesktopSendState extends ConsumerState { ), textAlign: TextAlign.right, inputFormatters: [ - AmountInputFormatter(decimals: 2, locale: locale), + AmountInputFormatter( + controller: baseAmountController, + decimals: 2, + locale: locale, + ), // // regex to validate a fiat amount with 2 decimal places // TextInputFormatter.withFunction((oldValue, newValue) => // RegExp(r'^([0-9]*[,.]?[0-9]{0,2}|[,.][0-9]{0,2})$') @@ -2127,12 +2172,13 @@ class _DesktopSendState extends ConsumerState { walletId: walletId, isToken: false, onCustomFeeSliderChanged: (value) => customFeeRate = value, - onCustomFeeOptionChanged: (value) { - isCustomFee = value; + onCustomFeeOptionChanged: () { customFeeRate = 1; - ethFee = null; + _ethFee.value = null; + }, + onCustomEip1559FeeOptionChanged: (value) { + _ethFee.value = value; }, - onCustomEip1559FeeOptionChanged: (value) => ethFee = value, ), if (coin is Ethereum) const SizedBox(height: 20), if (coin is Ethereum) @@ -2159,8 +2205,9 @@ class _DesktopSendState extends ConsumerState { readOnly: false, autocorrect: false, enableSuggestions: false, - keyboardType: const TextInputType.numberWithOptions(), + keyboardType: TextInputType.number, focusNode: _nonceFocusNode, + onChanged: (_) => setState(() {}), style: STextStyles.desktopTextExtraSmall(context).copyWith( color: Theme.of( context, @@ -2183,16 +2230,31 @@ class _DesktopSendState extends ConsumerState { ), ), ), + if (coin is Ethereum && !_nonceIsValid) + Padding( + padding: const EdgeInsets.only(top: 6, left: 12), + child: Text( + "Enter a non-negative whole number", + style: STextStyles.errorSmall(context), + ), + ), const SizedBox(height: 36), - PrimaryButton( - buttonHeight: ButtonHeight.l, - label: ref.watch(pIsSlatepack(widget.walletId)) - ? "Create slatepack" - : "Preview send", - enabled: ref.watch(pPreviewTxButtonEnabled(coin)), - onPressed: ref.watch(pPreviewTxButtonEnabled(coin)) - ? previewSend - : null, + ValueListenableBuilder( + valueListenable: _ethFee, + builder: (context, ethFee, _) { + final enabled = + previewEnabled && + _nonceIsValid && + (!needsEthFee || ethFee != null); + return PrimaryButton( + buttonHeight: ButtonHeight.l, + label: ref.watch(pIsSlatepack(widget.walletId)) + ? "Create slatepack" + : "Preview send", + enabled: enabled, + onPressed: enabled ? previewSend : null, + ); + }, ), ], ); diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send_fee_form.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send_fee_form.dart index b1e2b468e1..b17725b382 100644 --- a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send_fee_form.dart +++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send_fee_form.dart @@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../../pages/send_view/sub_widgets/transaction_fee_selection_sheet.dart'; import '../../../../providers/providers.dart'; +import '../../../../providers/ui/fee_rate_type_state_provider.dart'; import '../../../../providers/ui/preview_tx_button_state_provider.dart'; import '../../../../providers/wallet/desktop_fee_providers.dart'; import '../../../../providers/wallet/public_private_balance_state_provider.dart'; @@ -39,8 +40,8 @@ class DesktopSendFeeForm extends ConsumerStatefulWidget { final String walletId; final bool isToken; final void Function(int) onCustomFeeSliderChanged; - final void Function(bool) onCustomFeeOptionChanged; - final void Function(EthEIP1559Fee)? onCustomEip1559FeeOptionChanged; + final VoidCallback onCustomFeeOptionChanged; + final void Function(EthEIP1559Fee?)? onCustomEip1559FeeOptionChanged; @override ConsumerState createState() => _DesktopSendFeeFormState(); @@ -58,15 +59,6 @@ class _DesktopSendFeeFormState extends ConsumerState { bool get isEth => cryptoCurrency is Ethereum; - bool _isCustomFeeValue = false; - bool get _isCustomFee => _isCustomFeeValue; - set _isCustomFee(bool newValue) { - if (_isCustomFeeValue != newValue) { - _isCustomFeeValue = newValue; - widget.onCustomFeeOptionChanged.call(_isCustomFeeValue); - } - } - (FeeRateType, String?, String?)? feeSelectionResult; Amount _addFiroOpReturnFee({ @@ -100,10 +92,24 @@ class _DesktopSendFeeFormState extends ConsumerState { void initState() { super.initState(); cryptoCurrency = ref.read(pWalletCoin(widget.walletId)); + + // The fee rate type provider is global and never disposed. Reset it here + // so a stale custom selection from another wallet/send form can't cause a + // send using an unset custom fee rate. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + ref.read(feeRateTypeDesktopStateProvider.state).state = + FeeRateType.average; + } + }); } @override Widget build(BuildContext context) { + final isCustomFee = ref.watch(feeRateTypeDesktopStateProvider).isCustom; + final locale = ref.watch( + localeServiceChangeNotifierProvider.select((value) => value.locale), + ); final canEditFees = isEth || cryptoCurrency is Solana || @@ -124,6 +130,7 @@ class _DesktopSendFeeFormState extends ConsumerState { CustomTextButton( text: "Edit", onTap: () async { + final wasCustomFee = isCustomFee; feeSelectionResult = await showDialog<(FeeRateType, String?, String?)?>( context: context, @@ -134,12 +141,9 @@ class _DesktopSendFeeFormState extends ConsumerState { ); if (feeSelectionResult != null) { - if (_isCustomFee && - feeSelectionResult!.$1 != FeeRateType.custom) { - _isCustomFee = false; - } else if (!_isCustomFee && - feeSelectionResult!.$1 == FeeRateType.custom) { - _isCustomFee = true; + final selectedIsCustomFee = feeSelectionResult!.$1.isCustom; + if (wasCustomFee != selectedIsCustomFee) { + widget.onCustomFeeOptionChanged.call(); } } @@ -150,7 +154,7 @@ class _DesktopSendFeeFormState extends ConsumerState { ), child: Text( "Transaction fee" - "${_isCustomFee ? "" : " (${isEth ? "max" : "estimated"})"}", + "${isCustomFee ? "" : " (${isEth ? "max" : "estimated"})"}", style: STextStyles.desktopTextExtraSmall(context).copyWith( color: Theme.of( context, @@ -160,7 +164,7 @@ class _DesktopSendFeeFormState extends ConsumerState { ), ), const SizedBox(height: 10), - if (!_isCustomFee) + if (!isCustomFee) Padding( padding: const EdgeInsets.all(10), child: (feeSelectionResult?.$2 == null) @@ -340,15 +344,16 @@ class _DesktopSendFeeFormState extends ConsumerState { ], ), ), - if (_isCustomFee && isEth) + if (isCustomFee && isEth) EthFeeForm( + locale: locale, minGasLimit: widget.isToken ? kEthereumTokenMinGasLimit : kEthereumMinGasLimit, stateChanged: (value) => widget.onCustomEip1559FeeOptionChanged?.call(value), ), - if (_isCustomFee && !isEth) + if (isCustomFee && !isEth) Padding( padding: const EdgeInsets.only(bottom: 12, top: 16), child: FeeSlider( diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_sol_token_send.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_sol_token_send.dart index cd4e227a51..67c4fff32e 100644 --- a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_sol_token_send.dart +++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_sol_token_send.dart @@ -16,6 +16,7 @@ import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../../models/isar/models/contact_entry.dart'; +import '../../../../models/isar/models/solana/sol_contract.dart'; import '../../../../models/paynym/paynym_account_lite.dart'; import '../../../../models/send_view_auto_fill_data.dart'; import '../../../../pages/send_view/confirm_transaction_view.dart'; @@ -25,8 +26,9 @@ import '../../../../providers/ui/preview_tx_button_state_provider.dart'; import '../../../../themes/stack_colors.dart'; import '../../../../utilities/address_utils.dart'; import '../../../../utilities/amount/amount.dart'; -import '../../../../utilities/amount/amount_formatter.dart'; +import '../../../../utilities/amount/amount_field_relocalization.dart'; import '../../../../utilities/amount/amount_input_formatter.dart'; +import '../../../../utilities/amount/amount_unit.dart'; import '../../../../utilities/clipboard_interface.dart'; import '../../../../utilities/constants.dart'; import '../../../../utilities/logger.dart'; @@ -50,6 +52,23 @@ import '../../../../widgets/textfield_icon_button.dart'; import '../../../desktop_home_view.dart'; import 'address_book_address_chooser/address_book_address_chooser.dart'; +Amount? parseDesktopSolTokenAmount( + String value, { + required String locale, + required CryptoCurrency coin, + required SolContract tokenContract, +}) => AmountUnit.normal.tryParse( + value, + locale: locale, + coin: coin, + tokenContract: tokenContract, +); + +Amount? parseDesktopSolTokenFiatAmount( + String value, { + required String locale, +}) => Amount.tryParseFiatString(value, locale: locale); + class DesktopSolTokenSend extends ConsumerStatefulWidget { const DesktopSolTokenSend({ super.key, @@ -372,52 +391,45 @@ class _DesktopSolTokenSendState extends ConsumerState { void _cryptoAmountChanged() async { if (!_cryptoAmountChangeLock) { - // Get the token's decimal places for proper amount parsing - final tokenDecimals = ref.read(pCurrentSolanaTokenWallet)!.tokenDecimals; + // Get the token's decimal places for proper amount parsing. May still + // be null while the token wallet is loading (e.g. a locale change + // fires the relocalization listener before init completes). + final tokenWallet = ref.read(pCurrentSolanaTokenWallet); + if (tokenWallet == null) return; if (cryptoAmountController.text.isNotEmpty && cryptoAmountController.text != "." && cryptoAmountController.text != ",") { - try { - // Parse the amount using the token's decimal places, not the coin's - final inputDecimal = Decimal.parse( - cryptoAmountController.text.replaceFirst(",", "."), - ); - final cryptoAmount = Amount.fromDecimal( - inputDecimal, - fractionDigits: tokenDecimals, - ); - - // Only proceed if the parsed amount is valid - if (cryptoAmount.raw > BigInt.zero) { - _amountToSend = cryptoAmount; - if (_cachedAmountToSend != null && - _cachedAmountToSend == _amountToSend) { - return; - } - _cachedAmountToSend = _amountToSend; - - final price = ref - .read(priceAnd24hChangeNotifierProvider) - .getTokenPrice(ref.read(pCurrentSolanaTokenWallet)!.tokenMint) - ?.value; - - if (price != null && price > Decimal.zero) { - final String fiatAmountString = - Amount.fromDecimal( - _amountToSend!.decimal * price, - fractionDigits: 2, - ).fiatString( - locale: ref - .read(localeServiceChangeNotifierProvider) - .locale, - ); - - baseAmountController.text = fiatAmountString; - } + final parsedAmount = parseDesktopSolTokenAmount( + cryptoAmountController.text, + locale: ref.read(localeServiceChangeNotifierProvider).locale, + coin: coin, + tokenContract: tokenWallet.solContract, + ); + if (parsedAmount != null) { + _amountToSend = parsedAmount; + if (_cachedAmountToSend != null && + _cachedAmountToSend == _amountToSend) { + return; } - } catch (e) { - // Probably an invalid decimal input. + _cachedAmountToSend = _amountToSend; + + final price = ref + .read(priceAnd24hChangeNotifierProvider) + .getTokenPrice(tokenWallet.tokenMint) + ?.value; + + if (price != null && price > Decimal.zero) { + final fiatAmount = Amount.fromDecimal( + _amountToSend!.decimal * price, + fractionDigits: 2, + ); + baseAmountController.text = Amount.formatEditableDecimal( + fiatAmount.decimal, + locale: ref.read(localeServiceChangeNotifierProvider).locale, + ); + } + } else { _amountToSend = null; _cachedAmountToSend = null; baseAmountController.text = ""; @@ -496,14 +508,22 @@ class _DesktopSolTokenSendState extends ConsumerState { // autofill amount field if (paymentData.amount != null) { - final Amount amount = Decimal.parse(paymentData.amount!).toAmount( + final amount = Amount.tryParseCanonicalAmount( + paymentData.amount!, fractionDigits: ref.read(pCurrentSolanaTokenWallet)!.tokenDecimals, + truncateOverprecision: true, ); - cryptoAmountController.text = ref - .read(pAmountFormatter(coin)) - .format(amount, withUnitName: false); - - _amountToSend = amount; + if (amount != null) { + cryptoAmountController.text = Amount.formatEditableDecimal( + amount.decimal, + locale: ref.read(localeServiceChangeNotifierProvider).locale, + ); + _amountToSend = amount; + } else { + cryptoAmountController.clear(); + _amountToSend = null; + _cachedAmountToSend = null; + } } _updatePreviewButtonState(_address, _amountToSend); @@ -555,15 +575,12 @@ class _DesktopSolTokenSendState extends ConsumerState { .read(pCurrentSolanaTokenWallet)! .tokenDecimals; - if (baseAmountString.isNotEmpty && - baseAmountString != "." && - baseAmountString != ",") { - final baseAmount = baseAmountString.contains(",") - ? Decimal.parse( - baseAmountString.replaceFirst(",", "."), - ).toAmount(fractionDigits: 2) - : Decimal.parse(baseAmountString).toAmount(fractionDigits: 2); + final baseAmount = parseDesktopSolTokenFiatAmount( + baseAmountString, + locale: ref.read(localeServiceChangeNotifierProvider).locale, + ); + if (baseAmount != null) { final Decimal? _price = ref .read(priceAnd24hChangeNotifierProvider) .getTokenPrice(ref.read(pCurrentSolanaTokenWallet)!.tokenMint) @@ -583,15 +600,15 @@ class _DesktopSolTokenSendState extends ConsumerState { } _cachedAmountToSend = _amountToSend; - final amountString = ref - .read(pAmountFormatter(coin)) - .format(_amountToSend!, withUnitName: false); - _cryptoAmountChangeLock = true; - cryptoAmountController.text = amountString; + cryptoAmountController.text = Amount.formatEditableDecimal( + _amountToSend!.decimal, + locale: ref.read(localeServiceChangeNotifierProvider).locale, + ); _cryptoAmountChangeLock = false; } else { _amountToSend = Decimal.zero.toAmount(fractionDigits: tokenDecimals); + _cachedAmountToSend = null; _cryptoAmountChangeLock = true; cryptoAmountController.text = ""; _cryptoAmountChangeLock = false; @@ -609,8 +626,9 @@ class _DesktopSolTokenSendState extends ConsumerState { )), ); - cryptoAmountController.text = balance.spendable.decimal.toStringAsFixed( - tokenWallet.tokenDecimals, + cryptoAmountController.text = Amount.formatEditableDecimal( + balance.spendable.decimal, + locale: ref.read(localeServiceChangeNotifierProvider).locale, ); } @@ -639,7 +657,15 @@ class _DesktopSolTokenSendState extends ConsumerState { if (_data != null) { if (_data!.amount != null) { - cryptoAmountController.text = _data!.amount!.toString(); + final tokenWallet = ref.read(pCurrentSolanaTokenWallet)!; + final amount = Amount.fromDecimal( + _data!.amount!, + fractionDigits: tokenWallet.tokenDecimals, + ); + cryptoAmountController.text = Amount.formatEditableDecimal( + amount.decimal, + locale: ref.read(localeServiceChangeNotifierProvider).locale, + ); } sendToController.text = _data!.contactLabel; _address = _data!.address; @@ -670,6 +696,12 @@ class _DesktopSolTokenSendState extends ConsumerState { Widget build(BuildContext context) { debugPrint("BUILD: $runtimeType"); + listenForAmountRelocalization( + ref.listen, + controllers: [cryptoAmountController, baseAmountController], + onRelocalized: _cryptoAmountChanged, + ); + final tokenWallet = ref.watch(pCurrentSolanaTokenWallet); // If wallet is not initialized, show a placeholder. @@ -733,8 +765,9 @@ class _DesktopSolTokenSendState extends ConsumerState { textAlign: TextAlign.right, inputFormatters: [ AmountInputFormatter( + controller: cryptoAmountController, decimals: tokenWallet.tokenDecimals, - unit: ref.watch(pAmountUnit(coin)), + unit: AmountUnit.normal, locale: ref.watch( localeServiceChangeNotifierProvider.select( (value) => value.locale, @@ -805,6 +838,7 @@ class _DesktopSolTokenSendState extends ConsumerState { textAlign: TextAlign.right, inputFormatters: [ AmountInputFormatter( + controller: baseAmountController, decimals: 2, locale: ref.watch( localeServiceChangeNotifierProvider.select( diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_token_send.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_token_send.dart index f01cdd2464..4fe3f5b2a9 100644 --- a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_token_send.dart +++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_token_send.dart @@ -27,11 +27,14 @@ import '../../../../providers/wallet/desktop_fee_providers.dart'; import '../../../../themes/stack_colors.dart'; import '../../../../utilities/address_utils.dart'; import '../../../../utilities/amount/amount.dart'; +import '../../../../utilities/amount/amount_field_relocalization.dart'; import '../../../../utilities/amount/amount_formatter.dart'; import '../../../../utilities/amount/amount_input_formatter.dart'; import '../../../../utilities/amount/amount_unit.dart'; import '../../../../utilities/clipboard_interface.dart'; import '../../../../utilities/constants.dart'; +import '../../../../utilities/enums/fee_rate_type_enum.dart'; +import '../../../../utilities/integer_input.dart'; import '../../../../utilities/logger.dart'; import '../../../../utilities/text_styles.dart'; import '../../../../utilities/util.dart'; @@ -55,6 +58,9 @@ import '../../../desktop_home_view.dart'; import 'address_book_address_chooser/address_book_address_chooser.dart'; import 'desktop_send_fee_form.dart'; +Amount? parseDesktopTokenFiatAmount(String value, {required String locale}) => + Amount.tryParseFiatString(value, locale: locale); + class DesktopTokenSend extends ConsumerStatefulWidget { const DesktopTokenSend({ super.key, @@ -102,9 +108,18 @@ class _DesktopTokenSendState extends ConsumerState { bool _cryptoAmountChangeLock = false; late VoidCallback onCryptoAmountChanged; - EthEIP1559Fee? ethFee; + final _ethFee = ValueNotifier(null); + + ({bool isValid, int? value}) get _nonceInput => + parseOptionalIntegerInput(nonceController.text, minimum: 0); + + bool get _nonceIsValid => _nonceInput.isValid; Future previewSend() async { + final nonceInput = _nonceInput; + if (!nonceInput.isValid) return; + final nonce = nonceInput.value; + final tokenWallet = ref.read(pCurrentTokenWallet)!; final Amount amount = _amountToSend!; @@ -242,8 +257,8 @@ class _DesktopTokenSendState extends ConsumerState { ), ], feeRateType: ref.read(feeRateTypeDesktopStateProvider), - nonce: int.tryParse(nonceController.text), - ethEIP1559Fee: ethFee, + nonce: nonce, + ethEIP1559Fee: _ethFee.value, ), ); @@ -348,16 +363,23 @@ class _DesktopTokenSendState extends ConsumerState { nonceController.text = ""; _address = ""; _addressToggleFlag = false; + _syncFeeAmount(null); if (mounted) { setState(() {}); } } + void _syncFeeAmount(Amount? amount) { + final tokenDecimals = ref.read(pCurrentTokenWallet)!.tokenContract.decimals; + ref.read(sendAmountProvider.notifier).state = + amount ?? Amount.zeroWith(fractionDigits: tokenDecimals); + } + void _cryptoAmountChanged() async { if (!_cryptoAmountChangeLock) { final cryptoAmount = ref .read(pAmountFormatter(coin)) - .tryParse( + .tryParseEditable( cryptoAmountController.text, tokenContract: ref.read(pCurrentTokenWallet)!.tokenContract, ); @@ -376,13 +398,14 @@ class _DesktopTokenSendState extends ConsumerState { ?.value; if (price != null && price > Decimal.zero) { - final String fiatAmountString = - Amount.fromDecimal( - _amountToSend!.decimal * price, - fractionDigits: 2, - ).fiatString( - locale: ref.read(localeServiceChangeNotifierProvider).locale, - ); + final fiatAmount = Amount.fromDecimal( + _amountToSend!.decimal * price, + fractionDigits: 2, + ); + final fiatAmountString = Amount.formatEditableDecimal( + fiatAmount.decimal, + locale: ref.read(localeServiceChangeNotifierProvider).locale, + ); baseAmountController.text = fiatAmountString; } @@ -397,7 +420,7 @@ class _DesktopTokenSendState extends ConsumerState { } String? _updateInvalidAddressText(String address) { - if (_data != null && _data!.contactLabel == address) { + if (_data != null && _data.contactLabel == address) { return null; } if (address.isNotEmpty && @@ -460,17 +483,26 @@ class _DesktopTokenSendState extends ConsumerState { // autofill amount field if (paymentData.amount != null) { - final Amount amount = Decimal.parse(paymentData.amount!).toAmount( + final amount = Amount.tryParseCanonicalAmount( + paymentData.amount!, fractionDigits: ref .read(pCurrentTokenWallet)! .tokenContract .decimals, + truncateOverprecision: true, ); - cryptoAmountController.text = ref - .read(pAmountFormatter(coin)) - .format(amount, withUnitName: false); - - _amountToSend = amount; + if (amount != null) { + cryptoAmountController.text = ref + .read(pAmountFormatter(coin)) + .formatEditable(amount); + _amountToSend = amount; + _syncFeeAmount(amount); + } else { + cryptoAmountController.clear(); + _amountToSend = null; + _cachedAmountToSend = null; + _syncFeeAmount(null); + } } _updatePreviewButtonState(_address, _amountToSend); @@ -523,15 +555,12 @@ class _DesktopTokenSendState extends ConsumerState { .tokenContract .decimals; - if (baseAmountString.isNotEmpty && - baseAmountString != "." && - baseAmountString != ",") { - final baseAmount = baseAmountString.contains(",") - ? Decimal.parse( - baseAmountString.replaceFirst(",", "."), - ).toAmount(fractionDigits: 2) - : Decimal.parse(baseAmountString).toAmount(fractionDigits: 2); + final baseAmount = parseDesktopTokenFiatAmount( + baseAmountString, + locale: ref.read(localeServiceChangeNotifierProvider).locale, + ); + if (baseAmount != null) { final Decimal? _price = ref .read(priceAnd24hChangeNotifierProvider) .getTokenPrice(ref.read(pCurrentTokenWallet)!.tokenContract.address) @@ -553,17 +582,14 @@ class _DesktopTokenSendState extends ConsumerState { final amountString = ref .read(pAmountFormatter(coin)) - .format( - _amountToSend!, - withUnitName: false, - tokenContract: ref.read(pCurrentTokenWallet)!.tokenContract, - ); + .formatEditable(_amountToSend!); _cryptoAmountChangeLock = true; cryptoAmountController.text = amountString; _cryptoAmountChangeLock = false; } else { _amountToSend = Decimal.zero.toAmount(fractionDigits: tokenDecimals); + _cachedAmountToSend = null; _cryptoAmountChangeLock = true; cryptoAmountController.text = ""; _cryptoAmountChangeLock = false; @@ -573,19 +599,17 @@ class _DesktopTokenSendState extends ConsumerState { } Future sendAllTapped() async { + final tokenWallet = ref.read(pCurrentTokenWallet)!; + final balance = ref.read( + pTokenBalance(( + walletId: walletId, + contractAddress: tokenWallet.tokenContract.address, + )), + ); cryptoAmountController.text = ref - .read( - pTokenBalance(( - walletId: walletId, - contractAddress: ref - .read(pCurrentTokenWallet)! - .tokenContract - .address, - )), - ) - .spendable - .decimal - .toStringAsFixed(ref.read(pCurrentTokenWallet)!.tokenContract.decimals); + .read(pAmountFormatter(coin)) + .formatEditable(balance.spendable); + _syncFeeAmount(balance.spendable); } @override @@ -611,11 +635,17 @@ class _DesktopTokenSendState extends ConsumerState { cryptoAmountController.addListener(onCryptoAmountChanged); if (_data != null) { - if (_data!.amount != null) { - cryptoAmountController.text = _data!.amount!.toString(); + if (_data.amount != null) { + final amount = _data.amount!.toAmount( + fractionDigits: ref.read(pCurrentTokenWallet)!.tokenContract.decimals, + ); + cryptoAmountController.text = ref + .read(pAmountFormatter(coin)) + .formatEditable(amount); + _syncFeeAmount(amount); } - sendToController.text = _data!.contactLabel; - _address = _data!.address; + sendToController.text = _data.contactLabel; + _address = _data.address; _addressToggleFlag = true; } @@ -649,6 +679,7 @@ class _DesktopTokenSendState extends ConsumerState { @override void dispose() { cryptoAmountController.removeListener(onCryptoAmountChanged); + _ethFee.dispose(); sendToController.dispose(); cryptoAmountController.dispose(); @@ -668,6 +699,18 @@ class _DesktopTokenSendState extends ConsumerState { debugPrint("BUILD: $runtimeType"); final tokenContract = ref.watch(pCurrentTokenWallet)!.tokenContract; + listenForAmountRelocalization( + ref.listen, + controllers: [cryptoAmountController, baseAmountController], + onRelocalized: _cryptoAmountChanged, + ); + final isCustomFee = ref.watch(feeRateTypeDesktopStateProvider).isCustom; + // ethFee is checked in the ValueListenableBuilder around the preview + // button so fee keystrokes don't rebuild this whole view. + final previewEnabled = ref + .watch(previewTokenTxButtonStateProvider.state) + .state; + final needsEthFee = isCustomFee; return Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -720,6 +763,7 @@ class _DesktopTokenSendState extends ConsumerState { textAlign: TextAlign.right, inputFormatters: [ AmountInputFormatter( + controller: cryptoAmountController, decimals: tokenContract.decimals, unit: ref.watch(pAmountUnit(coin)), locale: ref.watch( @@ -792,6 +836,7 @@ class _DesktopTokenSendState extends ConsumerState { textAlign: TextAlign.right, inputFormatters: [ AmountInputFormatter( + controller: baseAmountController, decimals: 2, locale: ref.watch( localeServiceChangeNotifierProvider.select( @@ -1036,10 +1081,12 @@ class _DesktopTokenSendState extends ConsumerState { walletId: walletId, isToken: true, onCustomFeeSliderChanged: (value) => {}, - onCustomFeeOptionChanged: (value) { - ethFee = null; + onCustomFeeOptionChanged: () { + _ethFee.value = null; + }, + onCustomEip1559FeeOptionChanged: (value) { + _ethFee.value = value; }, - onCustomEip1559FeeOptionChanged: (value) => ethFee = value, ), const SizedBox(height: 20), Text( @@ -1064,8 +1111,9 @@ class _DesktopTokenSendState extends ConsumerState { readOnly: false, autocorrect: false, enableSuggestions: false, - keyboardType: const TextInputType.numberWithOptions(), + keyboardType: TextInputType.number, focusNode: _nonceFocusNode, + onChanged: (_) => setState(() {}), style: STextStyles.desktopTextExtraSmall(context).copyWith( color: Theme.of( context, @@ -1088,14 +1136,29 @@ class _DesktopTokenSendState extends ConsumerState { ), ), ), + if (!_nonceIsValid) + Padding( + padding: const EdgeInsets.only(top: 6, left: 12), + child: Text( + "Enter a non-negative whole number", + style: STextStyles.errorSmall(context), + ), + ), const SizedBox(height: 36), - PrimaryButton( - buttonHeight: ButtonHeight.l, - label: "Preview send", - enabled: ref.watch(previewTokenTxButtonStateProvider.state).state, - onPressed: ref.watch(previewTokenTxButtonStateProvider.state).state - ? previewSend - : null, + ValueListenableBuilder( + valueListenable: _ethFee, + builder: (context, ethFee, _) { + final enabled = + previewEnabled && + _nonceIsValid && + (!needsEthFee || ethFee != null); + return PrimaryButton( + buttonHeight: ButtonHeight.l, + label: "Preview send", + enabled: enabled, + onPressed: enabled ? previewSend : null, + ); + }, ), ], ); diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/wallet_keys_desktop_popup.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/wallet_keys_desktop_popup.dart index 2c31098a2d..95b2855ecc 100644 --- a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/wallet_keys_desktop_popup.dart +++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/wallet_keys_desktop_popup.dart @@ -88,90 +88,16 @@ class WalletKeysDesktopPopup extends ConsumerWidget { const SizedBox(height: 6), frostData != null ? Column( - children: [ - Text("Keys", style: STextStyles.desktopTextMedium(context)), - const SizedBox(height: 8), - Center( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 32), - child: RoundedWhiteContainer( - borderColor: - Theme.of( - context, - ).extension()!.textFieldDefaultBG, - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 9, - ), - child: Row( - children: [ - Flexible( - child: SelectableText( - frostData!.keys, - style: STextStyles.desktopTextExtraExtraSmall( - context, - ), - textAlign: TextAlign.center, - ), - ), - const SizedBox(width: 10), - IconCopyButton(data: frostData!.keys), - // TODO [prio=low: Add QR code button and dialog. - ], - ), - ), - ), - ), - const SizedBox(height: 24), - Text("Config", style: STextStyles.desktopTextMedium(context)), - const SizedBox(height: 8), - Center( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 32), - child: RoundedWhiteContainer( - borderColor: - Theme.of( - context, - ).extension()!.textFieldDefaultBG, - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 9, - ), - child: Row( - children: [ - Flexible( - child: SelectableText( - frostData!.config, - style: STextStyles.desktopTextExtraExtraSmall( - context, - ), - textAlign: TextAlign.center, - ), - ), - const SizedBox(width: 10), - IconCopyButton(data: frostData!.config), - // TODO [prio=low: Add QR code button and dialog. - ], - ), - ), - ), - ), - if (frostData?.prevGen != null) const SizedBox(height: 24), - if (frostData?.prevGen != null) - Text( - "Previous generation Keys", - style: STextStyles.desktopTextMedium(context), - ), - if (frostData?.prevGen != null) const SizedBox(height: 8), - if (frostData?.prevGen != null) + children: [ + Text("Keys", style: STextStyles.desktopTextMedium(context)), + const SizedBox(height: 8), Center( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 32), child: RoundedWhiteContainer( - borderColor: - Theme.of( - context, - ).extension()!.textFieldDefaultBG, + borderColor: Theme.of( + context, + ).extension()!.textFieldDefaultBG, padding: const EdgeInsets.symmetric( horizontal: 12, vertical: 9, @@ -195,22 +121,19 @@ class WalletKeysDesktopPopup extends ConsumerWidget { ), ), ), - if (frostData?.prevGen != null) const SizedBox(height: 24), - if (frostData?.prevGen != null) + const SizedBox(height: 24), Text( - "Previous generation Config", + "Config", style: STextStyles.desktopTextMedium(context), ), - if (frostData?.prevGen != null) const SizedBox(height: 8), - if (frostData?.prevGen != null) + const SizedBox(height: 8), Center( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 32), child: RoundedWhiteContainer( - borderColor: - Theme.of( - context, - ).extension()!.textFieldDefaultBG, + borderColor: Theme.of( + context, + ).extension()!.textFieldDefaultBG, padding: const EdgeInsets.symmetric( horizontal: 12, vertical: 9, @@ -219,7 +142,7 @@ class WalletKeysDesktopPopup extends ConsumerWidget { children: [ Flexible( child: SelectableText( - frostData!.prevGen!.config, + frostData!.config, style: STextStyles.desktopTextExtraExtraSmall( context, ), @@ -227,48 +150,128 @@ class WalletKeysDesktopPopup extends ConsumerWidget { ), ), const SizedBox(width: 10), - IconCopyButton(data: frostData!.prevGen!.config), + IconCopyButton(data: frostData!.config), // TODO [prio=low: Add QR code button and dialog. ], ), ), ), ), - const SizedBox(height: 24), - ], - ) - : keyData != null - ? keyData is ViewOnlyWalletData - ? Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: ViewOnlyWalletDataWidget( - data: keyData as ViewOnlyWalletData, - ), - ) - : CustomTabView( - titles: [ - if (words.isNotEmpty) "Mnemonic", - if (keyData is XPrivData) "XPriv(s)", - if (keyData is CWKeyData) "Keys", - ], - children: [ - if (words.isNotEmpty) - Padding( - padding: const EdgeInsets.only(top: 16), - child: _Mnemonic(words: words), + if (frostData?.prevGen != null) const SizedBox(height: 24), + if (frostData?.prevGen != null) + Text( + "Previous generation Keys", + style: STextStyles.desktopTextMedium(context), + ), + if (frostData?.prevGen != null) const SizedBox(height: 8), + if (frostData?.prevGen != null) + Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 32), + child: RoundedWhiteContainer( + borderColor: Theme.of( + context, + ).extension()!.textFieldDefaultBG, + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 9, + ), + child: Row( + children: [ + Flexible( + child: SelectableText( + frostData!.prevGen!.keys, + style: + STextStyles.desktopTextExtraExtraSmall( + context, + ), + textAlign: TextAlign.center, + ), + ), + const SizedBox(width: 10), + IconCopyButton(data: frostData!.prevGen!.keys), + // TODO [prio=low: Add QR code button and dialog. + ], + ), + ), ), - if (keyData is XPrivData) - WalletXPrivs( - xprivData: keyData as XPrivData, - walletId: walletId, + ), + if (frostData?.prevGen != null) const SizedBox(height: 24), + if (frostData?.prevGen != null) + Text( + "Previous generation Config", + style: STextStyles.desktopTextMedium(context), + ), + if (frostData?.prevGen != null) const SizedBox(height: 8), + if (frostData?.prevGen != null) + Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 32), + child: RoundedWhiteContainer( + borderColor: Theme.of( + context, + ).extension()!.textFieldDefaultBG, + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 9, + ), + child: Row( + children: [ + Flexible( + child: SelectableText( + frostData!.prevGen!.config, + style: + STextStyles.desktopTextExtraExtraSmall( + context, + ), + textAlign: TextAlign.center, + ), + ), + const SizedBox(width: 10), + IconCopyButton( + data: frostData!.prevGen!.config, + ), + // TODO [prio=low: Add QR code button and dialog. + ], + ), + ), ), - if (keyData is CWKeyData) - CNWalletKeys( - cwKeyData: keyData as CWKeyData, - walletId: walletId, + ), + const SizedBox(height: 24), + ], + ) + : keyData != null + ? keyData is ViewOnlyWalletData + ? Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: ViewOnlyWalletDataWidget( + data: keyData as ViewOnlyWalletData, ), - ], - ) + ) + : CustomTabView( + titles: [ + if (words.isNotEmpty) "Mnemonic", + if (keyData is XPrivData) "XPriv(s)", + if (keyData is CWKeyData) "Keys", + ], + children: [ + if (words.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 16), + child: _Mnemonic(words: words), + ), + if (keyData is XPrivData) + WalletXPrivs( + xprivData: keyData as XPrivData, + walletId: walletId, + ), + if (keyData is CWKeyData) + CNWalletKeys( + cwKeyData: keyData as CWKeyData, + walletId: walletId, + ), + ], + ) : _Mnemonic(words: words), const SizedBox(height: 32), ], @@ -311,8 +314,9 @@ class _Mnemonic extends StatelessWidget { child: MnemonicTable( words: words, isDesktop: true, - itemBorderColor: - Theme.of(context).extension()!.buttonBackSecondary, + itemBorderColor: Theme.of( + context, + ).extension()!.buttonBackSecondary, ), ), const SizedBox(height: 24), diff --git a/lib/providers/exchange/exchange_form_state_provider.dart b/lib/providers/exchange/exchange_form_state_provider.dart index 4335552301..e9e73e9bf3 100644 --- a/lib/providers/exchange/exchange_form_state_provider.dart +++ b/lib/providers/exchange/exchange_form_state_provider.dart @@ -10,30 +10,33 @@ import 'package:decimal/decimal.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:tuple/tuple.dart'; + import '../../models/exchange/active_pair.dart'; import '../../models/exchange/response_objects/estimate.dart'; import '../../models/exchange/response_objects/range.dart'; -import '../global/locale_provider.dart'; import '../../services/exchange/exchange.dart'; import '../../services/exchange/exchange_response.dart'; import '../../utilities/amount/amount.dart'; -import '../../utilities/amount/amount_unit.dart'; - import '../../utilities/enums/exchange_rate_type_enum.dart'; -import '../../wallets/crypto_currency/crypto_currency.dart'; -import 'package:tuple/tuple.dart'; +import '../global/locale_provider.dart'; -final efEstimatesListProvider = StateProvider.family< - Tuple2>, Range?>?, - String>((ref, exchangeName) => null); +final efEstimatesListProvider = + StateProvider.family< + Tuple2>, Range?>?, + String + >((ref, exchangeName) => null); -final efRateTypeProvider = - StateProvider((ref) => ExchangeRateType.estimated); +final efRateTypeProvider = StateProvider( + (ref) => ExchangeRateType.estimated, +); -final efExchangeProvider = - StateProvider((ref) => Exchange.defaultExchange); -final efExchangeProviderNameProvider = - StateProvider((ref) => Exchange.defaultExchange.name); +final efExchangeProvider = StateProvider( + (ref) => Exchange.defaultExchange, +); +final efExchangeProviderNameProvider = StateProvider( + (ref) => Exchange.defaultExchange.name, +); final currentCombinedExchangeIdProvider = Provider((ref) { return "${ref.watch(efExchangeProvider).name}" @@ -52,17 +55,8 @@ final efSendAmountStringProvider = StateProvider((ref) { final decimal = ref.watch(efSendAmountProvider); String string = ""; if (decimal != null) { - final amount = Amount.fromDecimal(decimal, fractionDigits: decimal.scale); final locale = ref.watch(localeServiceChangeNotifierProvider).locale; - string = AmountUnit.normal.displayAmount( - amount: amount, - locale: locale, - coin: Nano( - CryptoCurrencyNetwork.main, - ), // use nano just to ensure decimal.scale < Coin.value.decimals - withUnitName: false, - maxDecimalPlaces: decimal.scale, - ); + string = Amount.formatEditableDecimal(decimal, locale: locale); } return string; @@ -78,17 +72,8 @@ final efReceiveAmountStringProvider = StateProvider((ref) { final decimal = ref.watch(efReceiveAmountProvider); String string = ""; if (decimal != null) { - final amount = Amount.fromDecimal(decimal, fractionDigits: decimal.scale); final locale = ref.watch(localeServiceChangeNotifierProvider).locale; - string = AmountUnit.normal.displayAmount( - amount: amount, - locale: locale, - coin: Nano( - CryptoCurrencyNetwork.main, - ), // use nano just to ensure decimal.scale < Coin.value.decimals - withUnitName: false, - maxDecimalPlaces: decimal.scale, - ); + string = Amount.formatEditableDecimal(decimal, locale: locale); } return string; @@ -112,10 +97,10 @@ final efEstimateProvider = StateProvider((ref) { ?.item1 .value ?.where((e) { - return e.exchangeProvider == provider && - e.fixedRate == fixedRate && - e.reversed == reversed; - }); + return e.exchangeProvider == provider && + e.fixedRate == fixedRate && + e.reversed == reversed; + }); Estimate? result; diff --git a/lib/providers/global/secure_store_provider.dart b/lib/providers/global/secure_store_provider.dart index d49d6c552f..76b8c28016 100644 --- a/lib/providers/global/secure_store_provider.dart +++ b/lib/providers/global/secure_store_provider.dart @@ -24,7 +24,9 @@ final secureStoreProvider = Provider((ref) { ); } else { return const SecureStorageWrapper( - store: FlutterSecureStorage(), + store: FlutterSecureStorage( + aOptions: AndroidOptions(resetOnError: false, migrateWithBackup: true), + ), isDesktop: false, ); } diff --git a/lib/services/exchange/change_now/change_now_exchange.dart b/lib/services/exchange/change_now/change_now_exchange.dart index 48389afeb6..1c2106829e 100644 --- a/lib/services/exchange/change_now/change_now_exchange.dart +++ b/lib/services/exchange/change_now/change_now_exchange.dart @@ -52,6 +52,7 @@ class ChangeNowExchange extends Exchange { toCurrency: to, toNetwork: toNetwork ?? "", address: addressTo, + extraId: extraId, rateId: estimate?.rateId, refundAddress: addressRefund, refundExtraId: refundExtraId, diff --git a/lib/services/exchange/cyphergoat/cyphergoat_exchange.dart b/lib/services/exchange/cyphergoat/cyphergoat_exchange.dart index 0189908834..dde981f1f0 100644 --- a/lib/services/exchange/cyphergoat/cyphergoat_exchange.dart +++ b/lib/services/exchange/cyphergoat/cyphergoat_exchange.dart @@ -159,6 +159,9 @@ class CypherGoatExchange extends Exchange { @override bool get supportsRefundAddress => false; + @override + bool get supportsExtraId => false; + @override Future>> getAllCurrencies( bool fixedRate, @@ -380,6 +383,12 @@ class CypherGoatExchange extends Exchange { ExchangeExceptionType.generic, ); } + if (extraId?.isNotEmpty == true || refundExtraId.isNotEmpty) { + throw ExchangeException( + "CypherGoat does not support destination or refund memos", + ExchangeExceptionType.generic, + ); + } final response = await CypherGoatAPI.createSwap( coin1: from, diff --git a/lib/services/exchange/exchange.dart b/lib/services/exchange/exchange.dart index 7868b9e286..b3d6faebd9 100644 --- a/lib/services/exchange/exchange.dart +++ b/lib/services/exchange/exchange.dart @@ -61,6 +61,9 @@ abstract class Exchange { bool get supportsRefundAddress => true; + /// Whether createTrade forwards a payout memo/destination tag to the API. + bool get supportsExtraId => true; + Future>> getAllCurrencies(bool fixedRate); // Future>> getPairedCurrencies( diff --git a/lib/services/exchange/nanswap/api_response_models/n_trade.dart b/lib/services/exchange/nanswap/api_response_models/n_trade.dart index f26e19f3f8..b9355cda3c 100644 --- a/lib/services/exchange/nanswap/api_response_models/n_trade.dart +++ b/lib/services/exchange/nanswap/api_response_models/n_trade.dart @@ -17,6 +17,9 @@ class NTrade { final String? fromNetwork; final String? toNetwork; + String get payInNetwork => fromNetwork ?? from; + String get payOutNetwork => toNetwork ?? to; + NTrade({ required this.id, required this.from, diff --git a/lib/services/exchange/nanswap/nanswap_exchange.dart b/lib/services/exchange/nanswap/nanswap_exchange.dart index a26a35cfb9..6ef4873b21 100644 --- a/lib/services/exchange/nanswap/nanswap_exchange.dart +++ b/lib/services/exchange/nanswap/nanswap_exchange.dart @@ -1,4 +1,5 @@ import 'package:decimal/decimal.dart'; +import 'package:flutter/foundation.dart'; import 'package:uuid/uuid.dart'; import '../../../app_config.dart'; @@ -11,14 +12,25 @@ import '../../../models/isar/exchange_cache/pair.dart'; import '../exchange.dart'; import '../exchange_response.dart'; import 'api_response_models/n_estimate.dart'; +import 'api_response_models/n_trade.dart'; import 'nanswap_api.dart'; +typedef NanswapOrderLookup = + Future> Function({required String id}); + class NanswapExchange extends Exchange { - NanswapExchange._(); + NanswapExchange._({NanswapOrderLookup? getOrder}) + : _getOrder = getOrder ?? NanswapAPI.instance.getOrder; + + @visibleForTesting + NanswapExchange.forTesting({required NanswapOrderLookup getOrder}) + : this._(getOrder: getOrder); static NanswapExchange? _instance; static NanswapExchange get instance => _instance ??= NanswapExchange._(); + final NanswapOrderLookup _getOrder; + static const exchangeName = "Nanswap"; static const filter = ["BTC", "BAN", "XNO"]; @@ -92,13 +104,13 @@ class NanswapExchange extends Exchange { payInCurrency: from, payInAmount: t.expectedAmountFrom.toString(), payInAddress: t.payinAddress, - payInNetwork: t.toNetwork ?? t.to, + payInNetwork: t.payInNetwork, payInExtraId: t.payinExtraId ?? "", payInTxid: t.payinHash ?? "", payOutCurrency: to, payOutAmount: t.expectedAmountTo.toString(), payOutAddress: t.payoutAddress, - payOutNetwork: t.fromNetwork ?? t.from, + payOutNetwork: t.payOutNetwork, payOutExtraId: "", payOutTxid: t.payoutHash ?? "", refundAddress: "", @@ -319,7 +331,7 @@ class NanswapExchange extends Exchange { @override Future> getTrade(String tradeId) async { try { - final response = await NanswapAPI.instance.getOrder(id: tradeId); + final response = await _getOrder(id: tradeId); if (response.exception != null) { return ExchangeResponse(exception: response.exception); @@ -338,13 +350,13 @@ class NanswapExchange extends Exchange { payInCurrency: t.from, payInAmount: t.expectedAmountFrom.toString(), payInAddress: t.payinAddress, - payInNetwork: t.toNetwork ?? t.to, + payInNetwork: t.payInNetwork, payInExtraId: t.payinExtraId ?? "", payInTxid: t.payinHash ?? "", payOutCurrency: t.to, payOutAmount: t.expectedAmountTo.toString(), payOutAddress: t.payoutAddress, - payOutNetwork: t.fromNetwork ?? t.from, + payOutNetwork: t.payOutNetwork, payOutExtraId: "", payOutTxid: t.payoutHash ?? "", refundAddress: "", @@ -377,7 +389,7 @@ class NanswapExchange extends Exchange { @override Future> updateTrade(Trade trade) async { try { - final response = await NanswapAPI.instance.getOrder(id: trade.tradeId); + final response = await _getOrder(id: trade.tradeId); if (response.exception != null) { return ExchangeResponse(exception: response.exception); @@ -396,13 +408,13 @@ class NanswapExchange extends Exchange { payInCurrency: t.from, payInAmount: t.expectedAmountFrom.toString(), payInAddress: t.payinAddress, - payInNetwork: t.toNetwork ?? trade.payInNetwork, + payInNetwork: t.payInNetwork, payInExtraId: t.payinExtraId ?? trade.payInExtraId, payInTxid: t.payinHash ?? trade.payInTxid, payOutCurrency: t.to, payOutAmount: t.expectedAmountTo.toString(), payOutAddress: t.payoutAddress, - payOutNetwork: t.fromNetwork ?? trade.payOutNetwork, + payOutNetwork: t.payOutNetwork, payOutExtraId: trade.payOutExtraId, payOutTxid: t.payoutHash ?? trade.payOutTxid, refundAddress: trade.refundAddress, diff --git a/lib/services/exchange/trocador/trocador_exchange.dart b/lib/services/exchange/trocador/trocador_exchange.dart index 800f921816..9f9e5f9deb 100644 --- a/lib/services/exchange/trocador/trocador_exchange.dart +++ b/lib/services/exchange/trocador/trocador_exchange.dart @@ -77,9 +77,9 @@ class TrocadorExchange extends Exchange { toNetwork: onlySupportedNetwork, toAmount: amount.toString(), receivingAddress: addressTo, - receivingMemo: null, + receivingMemo: extraId?.isNotEmpty == true ? extraId : null, refundAddress: addressRefund, - refundMemo: null, + refundMemo: refundExtraId.isNotEmpty ? refundExtraId : null, exchangeProvider: estimate!.exchangeProvider!, isFixedRate: fixedRate, ) @@ -92,9 +92,9 @@ class TrocadorExchange extends Exchange { toNetwork: onlySupportedNetwork, fromAmount: amount.toString(), receivingAddress: addressTo, - receivingMemo: null, + receivingMemo: extraId?.isNotEmpty == true ? extraId : null, refundAddress: addressRefund, - refundMemo: null, + refundMemo: refundExtraId.isNotEmpty ? refundExtraId : null, exchangeProvider: estimate!.exchangeProvider!, isFixedRate: fixedRate, ); diff --git a/lib/services/notifications_api.dart b/lib/services/notifications_api.dart index 4263c951f1..1e2c6e1fd1 100644 --- a/lib/services/notifications_api.dart +++ b/lib/services/notifications_api.dart @@ -12,6 +12,7 @@ import 'dart:async'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; +import '../app_config.dart'; import '../models/notification_model.dart'; import '../utilities/logger.dart'; import '../utilities/prefs.dart'; @@ -54,14 +55,31 @@ abstract final class NotificationApi { defaultActionName: "temporary_stack_wallet", ); const macOS = DarwinInitializationSettings(); - const settings = InitializationSettings( + final (windowsAppUserModelId, windowsGuid) = switch (AppConfig.appName) { + "Campfire" => ( + "CypherStack.Campfire", + "fe1bb964-a80c-45cd-a5d6-c95b2d8b1142", + ), + "Stack Duo" => ( + "CypherStack.StackDuo", + "7b01c69c-c282-493e-98a1-6a5cd3153049", + ), + _ => ("CypherStack.StackWallet", "986f4b88-0a22-42c2-a005-e5e150c8653f"), + }; + final windows = WindowsInitializationSettings( + appName: AppConfig.appName, + appUserModelId: windowsAppUserModelId, + guid: windowsGuid, + ); + final settings = InitializationSettings( android: android, iOS: iOS, linux: linux, macOS: macOS, + windows: windows, ); await _notifications.initialize( - settings, + settings: settings, // onDidReceiveNotificationResponse: (payload) async { // onNotifications.add(payload.payload); // }, @@ -79,7 +97,7 @@ abstract final class NotificationApi { static Future clearNotification(int id) async { await init(); - await _notifications.cancel(id); + await _notifications.cancel(id: id); } //=================================== @@ -94,10 +112,10 @@ abstract final class NotificationApi { await init(); final id = await prefs.incrementCurrentNotificationIndex(); await _notifications.show( - id, - title, - body, - await _notificationDetails(), + id: id, + title: title, + body: body, + notificationDetails: await _notificationDetails(), payload: payload, ); return id; diff --git a/lib/services/notifications_service.dart b/lib/services/notifications_service.dart index 4eca542e06..fa7575d314 100644 --- a/lib/services/notifications_service.dart +++ b/lib/services/notifications_service.dart @@ -109,7 +109,7 @@ class NotificationsService extends ChangeNotifier { _timer = Timer.periodic(notificationRefreshInterval, (_) { Logging.instance.d("Periodic notifications update check"); if (prefs.externalCalls) { - _checkTrades(); + unawaited(_checkTrades()); } _checkTransactions(); }); @@ -159,21 +159,20 @@ class NotificationsService extends ChangeNotifier { torEnabled: node.torEnabled, clearnetEnabled: node.clearnetEnabled, ); - final failovers = - nodeService - .failoverNodesFor(currency: coin) - .map( - (e) => ElectrumXNode( - address: e.host, - port: e.port, - name: e.name, - id: e.id, - useSSL: e.useSSL, - torEnabled: node.torEnabled, - clearnetEnabled: node.clearnetEnabled, - ), - ) - .toList(); + final failovers = nodeService + .failoverNodesFor(currency: coin) + .map( + (e) => ElectrumXNode( + address: e.host, + port: e.port, + name: e.name, + id: e.id, + useSSL: e.useSSL, + torEnabled: node.torEnabled, + clearnetEnabled: node.clearnetEnabled, + ), + ) + .toList(); final client = ElectrumXClient.from( node: eNode, @@ -233,7 +232,7 @@ class NotificationsService extends ChangeNotifier { } } - void _checkTrades() async { + Future _checkTrades() async { for (final notification in _watchedChangeNowTradeNotifications) { final id = notification.changeNowId!; @@ -243,7 +242,7 @@ class NotificationsService extends ChangeNotifier { ); if (trades.isEmpty) { - return; + continue; } final oldTrade = trades.first; late final ExchangeResponse response; @@ -252,11 +251,11 @@ class NotificationsService extends ChangeNotifier { final exchange = Exchange.fromName(oldTrade.exchangeName); response = await exchange.updateTrade(oldTrade); } catch (_) { - return; + continue; } if (response.value == null) { - return; + continue; } final trade = response.value!; @@ -371,11 +370,10 @@ class NotificationsService extends ChangeNotifier { } Future markAsRead(int id, bool shouldNotifyListeners) async { - final model = - DB.instance.get( - boxName: DB.boxNameNotifications, - key: id, - )!; + final model = DB.instance.get( + boxName: DB.boxNameNotifications, + key: id, + )!; await DB.instance.put( boxName: DB.boxNameNotifications, key: model.id, diff --git a/lib/services/price.dart b/lib/services/price.dart index 7af1ec2ba8..23f1cf2903 100644 --- a/lib/services/price.dart +++ b/lib/services/price.dart @@ -159,16 +159,14 @@ class PriceAPI { for (final map in coinGeckoData) { final String coinName = map["name"] as String; - late CryptoCurrency coin; - try { - coin = AppConfig.getCryptoCurrencyByPrettyName( - coinName == "Factor" ? "Fact0rn" : coinName, - ); - } catch (e, s) { + final coins = AppConfig.coins.where( + (coin) => + coin.network == CryptoCurrencyNetwork.main && + _coinToIdMap[coin.runtimeType] == map["id"], + ); + if (coins.isEmpty) { Logging.instance.e( "Failed to find matching app coin for $coinName. Moving on", - error: e, - stackTrace: s, ); continue; } @@ -179,9 +177,13 @@ class PriceAPI { ? double.parse(map["price_change_percentage_24h"].toString()) : 0.0; - result[coin] = (value: price, change24h: change24h); + for (final coin in coins) { + result[coin] = (value: price, change24h: change24h); + } } catch (_) { - result.remove(coin); + for (final coin in coins) { + result.remove(coin); + } } } diff --git a/lib/services/wallets.dart b/lib/services/wallets.dart index e1d38149b8..73ca76ad5d 100644 --- a/lib/services/wallets.dart +++ b/lib/services/wallets.dart @@ -24,7 +24,10 @@ import '../utilities/prefs.dart'; import '../utilities/stack_file_system.dart'; import '../wallets/crypto_currency/crypto_currency.dart'; import '../wallets/crypto_currency/intermediate/cryptonote_currency.dart'; +import '../wallets/crypto_currency/intermediate/frost_currency.dart'; +import '../wallets/isar/models/frost_wallet_info.dart'; import '../wallets/isar/models/wallet_info.dart'; +import '../wallets/wallet/impl/bitcoin_frost_wallet.dart'; import '../wallets/wallet/impl/epiccash_wallet.dart'; import '../wallets/wallet/impl/mimblewimblecoin_wallet.dart'; import '../wallets/wallet/intermediate/cryptonote_wallet.dart'; @@ -108,6 +111,7 @@ class Wallets { SecureStorageInterface secureStorage, ) async { final walletId = info.walletId; + final isFrostWallet = info.coin is FrostCurrency; Logging.instance.d("deleteWallet called with walletId=$walletId"); final wallet = _wallets[walletId]; @@ -123,6 +127,13 @@ class Wallets { key: Wallet.getViewOnlyWalletDataSecStoreKey(walletId: walletId), ); + if (isFrostWallet) { + await BitcoinFrostWallet.deleteSecureStorage( + walletId: walletId, + secureStorage: secureStorage, + ); + } + if (info.coin is CryptonoteCurrency) { await _deleteCryptonoteWalletFilesHelper(info); } else if (info.coin is Epiccash) { @@ -184,6 +195,9 @@ class Wallets { } await mainDB.isar.writeTxn(() async { + if (isFrostWallet) { + await mainDB.isar.frostWalletInfo.deleteByWalletId(walletId); + } await mainDB.isar.walletInfo.deleteByWalletId(walletId); }); @@ -669,8 +683,24 @@ class Wallets { Future _deleteWallet(String walletId) async { // TODO proper clean up of other wallet data in addition to the following - await mainDB.isar.writeTxn( - () async => await mainDB.isar.walletInfo.deleteByWalletId(walletId), - ); + final info = await mainDB.isar.walletInfo + .where() + .walletIdEqualTo(walletId) + .findFirst(); + final isFrostWallet = + info != null && + AppConfig.getCryptoCurrencyFor(info.coinName) is FrostCurrency; + if (isFrostWallet) { + await BitcoinFrostWallet.deleteSecureStorage( + walletId: walletId, + secureStorage: nodeService.secureStorageInterface, + ); + } + await mainDB.isar.writeTxn(() async { + if (isFrostWallet) { + await mainDB.isar.frostWalletInfo.deleteByWalletId(walletId); + } + await mainDB.isar.walletInfo.deleteByWalletId(walletId); + }); } } diff --git a/lib/utilities/address_utils.dart b/lib/utilities/address_utils.dart index cb1f5f8ad6..cb57e48e81 100644 --- a/lib/utilities/address_utils.dart +++ b/lib/utilities/address_utils.dart @@ -24,6 +24,9 @@ class AddressUtils { 'recipient_name', 'tx_description', 'op_return', // For Rosen Bridge and other OP_RETURN protocols. + 'memo', // Stellar SEP-0007. + 'dt', // XRP destination tag. + 'destination_tag', // TODO [prio=med]: Add more recognized params for other coins. }; @@ -76,6 +79,8 @@ class AddressUtils { result["tx_description"] = Uri.decodeComponent(u.fragment); } } + } on FormatException { + rethrow; } catch (e, s) { Logging.instance.d( "Exception caught in parseUri($uri): $e", @@ -95,40 +100,37 @@ class AddressUtils { switch (lowerKey) { case 'amount': case 'tx_amount': - result['amount'] = _normalizeAmount(value); + final normalized = _normalizeAmount(value); + if (normalized == null) { + throw FormatException("Invalid payment URI amount: $value"); + } + result['amount'] = normalized; break; case 'label': case 'recipient_name': - result['label'] = Uri.decodeComponent(value); + result['label'] = value; break; case 'message': case 'tx_description': - result['message'] = Uri.decodeComponent(value); + result['message'] = value; break; case 'tx_payment_id': - result['tx_payment_id'] = Uri.decodeComponent(value); + result['tx_payment_id'] = value; break; default: - result[lowerKey] = Uri.decodeComponent(value); + result[lowerKey] = value; } } else { // Include unrecognized parameters as-is. - result[key] = Uri.decodeComponent(value); + result[key] = value; } }); return result; } - /// Normalizes amount value to a standard format. - static String _normalizeAmount(String amount) { - // Remove any non-numeric characters except for '.' - final sanitized = amount.replaceAll(RegExp(r'[^\d.]'), ''); - // Ensure only one decimal point - final parts = sanitized.split('.'); - if (parts.length > 2) { - return '${parts[0]}.${parts.sublist(1).join()}'; - } - return sanitized; + static String? _normalizeAmount(String amount) { + final trimmed = amount.trim(); + return RegExp(r'^(\d+(\.\d+)?|\.\d+)$').hasMatch(trimmed) ? trimmed : null; } /// Centralized method to handle various cryptocurrency URIs and return a common object. @@ -136,17 +138,13 @@ class AddressUtils { /// Returns null on failure to parse static PaymentUriData? parsePaymentUri(String uri, {Logging? logging}) { // hacky check its not just a bcash, ecash, or xel address - final parts = uri.split(":"); - if (parts.length == 2) { - if ([ - "xel", - "bitcoincash", - "bchtest", - "ecash", - "ectest", - ].contains(parts.first.toLowerCase())) { - return null; - } + const cashAddrSchemes = {"bitcoincash", "bchtest", "ecash", "ectest"}; + final parsedUri = Uri.tryParse(uri); + final scheme = parsedUri?.scheme.toLowerCase(); + if (parsedUri != null && + (scheme == "xel" || + (!parsedUri.hasQuery && cashAddrSchemes.contains(scheme)))) { + return null; } try { @@ -155,13 +153,16 @@ class AddressUtils { // Normalize the URI scheme. final String scheme = parsedData['scheme'] ?? ''; parsedData.remove('scheme'); + final address = parsedData['address']!.trim(); // Filter out unrecognized parameters. final filteredParams = _filterParams(parsedData); return PaymentUriData( scheme: scheme, - address: parsedData['address']!.trim(), + address: cashAddrSchemes.contains(scheme) + ? "$scheme:$address".toLowerCase() + : address, amount: filteredParams['amount'] ?? filteredParams['tx_amount'], label: filteredParams['label'] ?? filteredParams['recipient_name'], message: filteredParams['message'] ?? filteredParams['tx_description'], @@ -192,27 +193,32 @@ class AddressUtils { uriString = "$scheme:$address"; } - if (scheme.toLowerCase() == "monero") { - // Handle Monero-specific formatting. - if (filteredParams.containsKey("tx_description")) { - final description = filteredParams.remove("tx_description")!; - if (filteredParams.isNotEmpty) { - uriString += Uri(queryParameters: filteredParams).toString(); - } - uriString += "#${Uri.encodeComponent(description)}"; - } else if (filteredParams.isNotEmpty) { - uriString += Uri(queryParameters: filteredParams).toString(); - } - } else { - // General case for other cryptocurrencies. - if (filteredParams.isNotEmpty) { - uriString += Uri(queryParameters: filteredParams).toString(); - } + if (filteredParams.isNotEmpty) { + uriString += Uri(queryParameters: filteredParams).toString(); } return uriString; } + static String buildPaymentUriString({ + required String scheme, + required String address, + String? amount, + String? message, + }) { + final normalizedScheme = scheme.toLowerCase(); + final usesMoneroParameters = + normalizedScheme == "monero" || normalizedScheme == "wownero"; + final params = { + if (amount != null && amount.isNotEmpty) + usesMoneroParameters ? "tx_amount" : "amount": amount, + if (message != null && message.isNotEmpty) + usesMoneroParameters ? "tx_description" : "message": message, + }; + + return buildUriString(scheme, address, params); + } + /// returns empty if bad data static Map decodeQRSeedData(String data) { Map result = {}; @@ -385,6 +391,20 @@ class PaymentUriData { scheme ?? "", // empty will just return null ); + String? get memo { + for (final value in [ + paymentId, + additionalParams["memo"], + additionalParams["dt"], + additionalParams["destination_tag"], + ]) { + if (value?.isNotEmpty == true) { + return value; + } + } + return null; + } + PaymentUriData({ required this.address, this.scheme, diff --git a/lib/utilities/amount/amount.dart b/lib/utilities/amount/amount.dart index b31d87d7a0..bc337beb1b 100644 --- a/lib/utilities/amount/amount.dart +++ b/lib/utilities/amount/amount.dart @@ -32,35 +32,122 @@ class Amount { fractionDigits: fractionDigits, ); - static Amount? tryParseFiatString(String value, {required String locale}) { - final parts = value.split(" "); + static Decimal? tryParseEditableDecimal( + String value, { + required String locale, + }) { + return _tryParseCanonicalDecimal( + _normalizeEditableDecimal(value, locale: locale), + ); + } + + static Amount? tryParseEditableAmount( + String value, { + required String locale, + required int fractionDigits, + }) { + return tryParseCanonicalAmount( + _normalizeEditableDecimal(value, locale: locale), + fractionDigits: fractionDigits, + ); + } - if (parts.first.isEmpty) { + static final RegExp _canonicalDecimalPattern = RegExp( + r'^(?:\d+(?:\.\d+)?|\.\d+)$', + ); + + static Decimal? _tryParseCanonicalDecimal(String value) { + if (!_canonicalDecimalPattern.hasMatch(value)) { return null; } - String str = parts.first; - if (str.startsWith(RegExp(r'[+-]'))) { - str = str.substring(1); + return Decimal.tryParse(value); + } + + static Amount? tryParseCanonicalAmount( + String value, { + required int fractionDigits, + bool truncateOverprecision = false, + }) { + if (fractionDigits < 0) { + return null; } - if (str.isEmpty) { + final decimal = _tryParseCanonicalDecimal(value); + if (decimal == null) { return null; } - // get number symbols for decimal place and group separator - final numberSymbols = Util.getSymbolsFor(locale: locale); + // Excess trailing zeros are fine as long as the value stays exactly + // representable; the isInteger check below rejects real overprecision + // unless the caller opted into truncation (e.g. externally supplied + // QR/URI amounts). + final atomicValue = decimal.shift(fractionDigits); + if (!atomicValue.isInteger && !truncateOverprecision) { + return null; + } - final groupSeparator = numberSymbols?.GROUP_SEP ?? ","; - final decimalSeparator = numberSymbols?.DECIMAL_SEP ?? "."; + return Amount( + rawValue: atomicValue.toBigInt(), + fractionDigits: fractionDigits, + ); + } + + static String formatEditableDecimal(Decimal value, {required String locale}) { + return value.toString().replaceFirst(".", _decimalSeparator(locale)); + } + + static String formatFixedDecimal( + Decimal value, { + required int fractionDigits, + required String locale, + }) { + if (fractionDigits < 0) { + throw ArgumentError.value(fractionDigits, "fractionDigits"); + } + return value + .toStringAsFixed(fractionDigits) + .replaceFirst(".", _decimalSeparator(locale)); + } - str = str.replaceAll(groupSeparator, ""); + static String relocalizeEditableDecimal( + String value, { + required String sourceLocale, + required String targetLocale, + }) { + return value.replaceAll( + _decimalSeparator(sourceLocale), + _decimalSeparator(targetLocale), + ); + } - final decimalString = str.replaceFirst(decimalSeparator, "."); + static Amount? tryParseFiatString(String value, {required String locale}) { + return tryParseEditableAmount(value, locale: locale, fractionDigits: 2); + } - return Decimal.tryParse(decimalString)?.toAmount(fractionDigits: 2); + static String _normalizeEditableDecimal( + String value, { + required String locale, + }) { + final decimalSeparator = _decimalSeparator(locale); + // Editable input accepts only the locale's decimal separator. + if (decimalSeparator != "." && value.contains(".")) { + return ""; + } + String normalized = value.replaceAll(decimalSeparator, "."); + // A single trailing separator after digits reads as the number typed so + // far ("1." == 1); separator-only input stays invalid. + if (normalized.endsWith(".") && + normalized.length > 1 && + !normalized.substring(0, normalized.length - 1).contains(".")) { + normalized = normalized.substring(0, normalized.length - 1); + } + return normalized; } + static String _decimalSeparator(String locale) => + Util.getSymbolsFor(locale: locale)?.DECIMAL_SEP ?? "."; + // =========================================================================== // ======= Instance properties =============================================== diff --git a/lib/utilities/amount/amount_field_relocalization.dart b/lib/utilities/amount/amount_field_relocalization.dart new file mode 100644 index 0000000000..2e9634e301 --- /dev/null +++ b/lib/utilities/amount/amount_field_relocalization.dart @@ -0,0 +1,81 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../providers/global/locale_provider.dart'; +import 'amount.dart'; + +typedef ProviderListen = + void Function( + ProviderListenable provider, + void Function(T? previous, T next) listener, + ); + +/// Rewrites a controller's decimal separator while preserving its selection. +void relocalizeAmountController( + TextEditingController controller, { + required String sourceLocale, + required String targetLocale, +}) { + final value = controller.value; + final text = Amount.relocalizeEditableDecimal( + value.text, + sourceLocale: sourceLocale, + targetLocale: targetLocale, + ); + if (text == value.text) return; + + int mapOffset(int offset) { + int safeOffset = offset; + if (safeOffset < 0) { + safeOffset = 0; + } else if (safeOffset > value.text.length) { + safeOffset = value.text.length; + } + return Amount.relocalizeEditableDecimal( + value.text.substring(0, safeOffset), + sourceLocale: sourceLocale, + targetLocale: targetLocale, + ).length; + } + + final selection = value.selection.isValid + ? TextSelection( + baseOffset: mapOffset(value.selection.baseOffset), + extentOffset: mapOffset(value.selection.extentOffset), + affinity: value.selection.affinity, + isDirectional: value.selection.isDirectional, + ) + : value.selection; + controller.value = value.copyWith( + text: text, + selection: selection, + composing: TextRange.empty, + ); +} + +/// Rewrites the decimal separator in [controllers] when the app locale +/// changes so their text stays parseable by the locale-strict amount +/// parsers, then invokes [onRelocalized] so the caller can re-run any +/// parsing/validation that cached state from the old text. +/// +/// Must be called from a widget's build method with `ref.listen`. +void listenForAmountRelocalization( + ProviderListen listen, { + required List controllers, + VoidCallback? onRelocalized, +}) { + listen(localeServiceChangeNotifierProvider.select((value) => value.locale), ( + previous, + next, + ) { + if (previous == null || previous == next) return; + for (final controller in controllers) { + relocalizeAmountController( + controller, + sourceLocale: previous, + targetLocale: next, + ); + } + onRelocalized?.call(); + }); +} diff --git a/lib/utilities/amount/amount_formatter.dart b/lib/utilities/amount/amount_formatter.dart index 6a6f01f7b9..32fe5e2907 100644 --- a/lib/utilities/amount/amount_formatter.dart +++ b/lib/utilities/amount/amount_formatter.dart @@ -66,7 +66,11 @@ class AmountFormatter { ); } - Amount? tryParse(String string, {Contract? tokenContract}) { + String formatEditable(Amount amount) { + return unit.formatEditable(amount: amount, locale: locale); + } + + Amount? tryParseEditable(String string, {Contract? tokenContract}) { return unit.tryParse( string, locale: locale, diff --git a/lib/utilities/amount/amount_input_formatter.dart b/lib/utilities/amount/amount_input_formatter.dart index 2ecd9fe540..24271ca1ac 100644 --- a/lib/utilities/amount/amount_input_formatter.dart +++ b/lib/utilities/amount/amount_input_formatter.dart @@ -1,98 +1,206 @@ import 'dart:math'; import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; import '../util.dart'; import 'amount_unit.dart'; class AmountInputFormatter extends TextInputFormatter { + final TextEditingController controller; final int decimals; final String locale; final AmountUnit? unit; AmountInputFormatter({ + required this.controller, required this.decimals, required this.locale, this.unit, - }); + }) : assert(decimals >= 0); + + late final String _decimalSeparator = + Util.getSymbolsFor(locale: locale)?.DECIMAL_SEP ?? "."; + + late final int _maximumFractionDigits = unit == null + ? max(decimals, 0) + : max(decimals - unit!.shift, 0); + + // Formatters are frequently constructed inline in build methods that + // rebuild per keystroke, so the compiled patterns are cached globally + // instead of per instance. + static final Map<(String, int), RegExp> _patternCache = {}; + + late final RegExp _validPattern = _patternCache.putIfAbsent( + (_decimalSeparator, _maximumFractionDigits), + () => _maximumFractionDigits == 0 + ? RegExp(r'^\d*$') + : RegExp( + '^\\d*(?:${RegExp.escape(_decimalSeparator)}\\d{0,$_maximumFractionDigits})?\$', + ), + ); - @override - TextEditingValue formatEditUpdate( - TextEditingValue oldValue, - TextEditingValue newValue, - ) { - // get number symbols for decimal place and group separator - final numberSymbols = Util.getSymbolsFor(locale: locale); + static final Expando<_AmountInputRecovery> _recoveryCache = Expando(); - final decimalSeparator = numberSymbols?.DECIMAL_SEP ?? "."; - final groupSeparator = numberSymbols?.GROUP_SEP ?? ","; + (String, int, int?) get _configuration => (locale, decimals, unit?.shift); - String newText = newValue.text.replaceAll(groupSeparator, ""); + _AmountInputRecovery? get _activeRecovery { + final recovery = _recoveryCache[controller]; + return recovery?.configuration == _configuration ? recovery : null; + } - final selectionIndexFromTheRight = - newValue.text.length - newValue.selection.end; + bool _continuesActiveComposition( + _AmountInputRecovery recovery, + TextEditingValue oldValue, + ) => + !oldValue.composing.isCollapsed && + recovery.composingText == oldValue.text; - String? fraction; - if (newText.contains(decimalSeparator)) { - final parts = newText.split(decimalSeparator); + void _clearRecovery() => _recoveryCache[controller] = null; - if (parts.length > 2) { - return oldValue; - } + void _rememberComposition(TextEditingValue value, String composingText) { + if (value.text.isEmpty) { + _clearRecovery(); + return; + } - final fractionDigits = - unit == null ? decimals : max(decimals - unit!.shift, 0); + final selection = value.selection.isValid + ? TextSelection( + baseOffset: min( + max(value.selection.baseOffset, 0), + value.text.length, + ), + extentOffset: min( + max(value.selection.extentOffset, 0), + value.text.length, + ), + affinity: value.selection.affinity, + isDirectional: value.selection.isDirectional, + ) + : TextSelection.collapsed(offset: value.text.length); + _recoveryCache[controller] = _AmountInputRecovery( + configuration: _configuration, + value: value.copyWith(selection: selection, composing: TextRange.empty), + composingText: composingText, + ); + } - if (newText.startsWith(decimalSeparator)) { - if (newText.length - 1 > fractionDigits) { - newText = newText.substring(0, fractionDigits + 1); + @override + TextEditingValue formatEditUpdate( + TextEditingValue oldValue, + TextEditingValue newValue, + ) { + if (!newValue.composing.isCollapsed) { + if (_validPattern.hasMatch(oldValue.text)) { + _rememberComposition(oldValue, newValue.text); + } else { + final recovery = _activeRecovery; + if (recovery != null && + _continuesActiveComposition(recovery, oldValue)) { + _recoveryCache[controller] = _AmountInputRecovery( + configuration: _configuration, + value: recovery.value, + composingText: newValue.text, + ); + } else { + _clearRecovery(); } - - return TextEditingValue( - text: newText, - selection: TextSelection.collapsed( - offset: newText.length - selectionIndexFromTheRight, - ), - ); } + return newValue; + } - newText = parts.first; - if (parts.length == 2) { - fraction = parts.last; - } else { - fraction = ""; - } + if (_validPattern.hasMatch(newValue.text)) { + _clearRecovery(); + return newValue; + } - if (fraction.length > fractionDigits) { - fraction = fraction.substring(0, fractionDigits); - } + final recovery = _activeRecovery; + if (recovery != null && _continuesActiveComposition(recovery, oldValue)) { + _clearRecovery(); + return recovery.value; } - String newString; - final val = BigInt.tryParse(newText); - if (val == null || val < BigInt.one) { - newString = newText; - } else { - // insert group separator - final regex = RegExp(r'\B(?=(\d{3})+(?!\d))'); - newString = newText.replaceAllMapped( - regex, - (m) => "${m.group(0)}${numberSymbols?.GROUP_SEP ?? ","}", - ); + final oldTextIsValid = _validPattern.hasMatch(oldValue.text); + final isDeletingFromInvalidText = + !oldTextIsValid && + newValue.text.length < oldValue.text.length && + _canResultFromDeletion(oldValue.text, newValue.text); + if (isDeletingFromInvalidText) { + _clearRecovery(); + return newValue; } - if (fraction != null) { - newString += decimalSeparator; - if (fraction.isNotEmpty) { - newString += fraction; - } + if (oldTextIsValid) { + _clearRecovery(); + return oldValue; } + // Both texts are invalid, but are not the commit of the active composing + // value. A remembered value from an unrelated edit (or an earlier + // formatter configuration) must not replace the current input. + _clearRecovery(); + // Never strip characters from the middle — joining the surrounding digits + // would silently change the value ("1.5" must not become "15"). + return _validPrefix(newValue); + } + + TextEditingValue _validPrefix(TextEditingValue value) { + const asciiZeroCodeUnit = 0x30; + const asciiNineCodeUnit = 0x39; + bool separatorSeen = false; + int fractionDigits = 0; + int end = 0; + for (; end < value.text.length; end++) { + final char = value.text[end]; + final codeUnit = char.codeUnitAt(0); + if (codeUnit >= asciiZeroCodeUnit && codeUnit <= asciiNineCodeUnit) { + if (separatorSeen) { + if (fractionDigits >= _maximumFractionDigits) break; + fractionDigits++; + } + } else if (!separatorSeen && + _maximumFractionDigits > 0 && + char == _decimalSeparator) { + separatorSeen = true; + } else { + break; + } + } + final text = value.text.substring(0, end); return TextEditingValue( - text: newString, + text: text, selection: TextSelection.collapsed( - offset: newString.length - selectionIndexFromTheRight, + offset: min( + value.selection.isValid ? value.selection.end : text.length, + text.length, + ), ), ); } + + bool _canResultFromDeletion(String oldText, String newText) { + int newIndex = 0; + for ( + int oldIndex = 0; + oldIndex < oldText.length && newIndex < newText.length; + oldIndex++ + ) { + if (oldText.codeUnitAt(oldIndex) == newText.codeUnitAt(newIndex)) { + newIndex++; + } + } + return newIndex == newText.length; + } +} + +class _AmountInputRecovery { + final (String, int, int?) configuration; + final TextEditingValue value; + final String composingText; + + const _AmountInputRecovery({ + required this.configuration, + required this.value, + required this.composingText, + }); } diff --git a/lib/utilities/amount/amount_unit.dart b/lib/utilities/amount/amount_unit.dart index 0d96fbdeff..4af50c345e 100644 --- a/lib/utilities/amount/amount_unit.dart +++ b/lib/utilities/amount/amount_unit.dart @@ -196,51 +196,46 @@ extension AmountUnitExt on AmountUnit { } } + static final RegExp _rejectedInputChars = RegExp(r'[+\-~ \x09-\x0D]'); + + /// Parses user-editable amount text (digits plus the locale's decimal + /// separator only). Display strings (grouped, unit-suffixed, or + /// "~"-prefixed) are intentionally rejected: formatted display output + /// must never be re-parsed as input. Amount? tryParse( String value, { required String locale, required CryptoCurrency coin, - Contract? tokenContract, - bool overrideWithDecimalPlacesFromString = false, + Contract? tokenContract, }) { - final precisionLost = value.startsWith("~"); - - final parts = (precisionLost ? value.substring(1) : value).split(" "); - - if (parts.first.isEmpty) { + if (value.contains(_rejectedInputChars)) { return null; } - String str = parts.first; - if (str.startsWith(RegExp(r'[+-]'))) { - str = str.substring(1); - } - - if (str.isEmpty) { - return null; - } - - // get number symbols for decimal place and group separator - final numberSymbols = Util.getSymbolsFor(locale: locale); - - final groupSeparator = numberSymbols?.GROUP_SEP ?? ","; - final decimalSeparator = numberSymbols?.DECIMAL_SEP ?? "."; - - str = str.replaceAll(groupSeparator, ""); - - final decimalString = str.replaceFirst(decimalSeparator, "."); - final Decimal? decimal = Decimal.tryParse(decimalString); + final decimalPlaces = tokenContract?.decimals ?? coin.fractionDigits; + final realShift = math.min(shift, decimalPlaces); - if (decimal == null) { + final parsedUnitAmount = Amount.tryParseEditableAmount( + value, + locale: locale, + fractionDigits: decimalPlaces - realShift, + ); + if (parsedUnitAmount == null) { return null; } - final decimalPlaces = overrideWithDecimalPlacesFromString - ? decimal.scale - : tokenContract?.decimals ?? coin.fractionDigits; - final realShift = math.min(shift, decimalPlaces); + return Amount( + rawValue: parsedUnitAmount.raw, + fractionDigits: decimalPlaces, + ); + } - return decimal.shift(0 - realShift).toAmount(fractionDigits: decimalPlaces); + String formatEditable({required Amount amount, required String locale}) { + final realShift = math.min(shift, amount.fractionDigits); + return Amount.formatEditableDecimal( + amount.decimal.shift(realShift), + locale: locale, + ); } String displayAmount({ diff --git a/lib/utilities/desktop_password_service.dart b/lib/utilities/desktop_password_service.dart index 1649a5d3bf..aab071e49f 100644 --- a/lib/utilities/desktop_password_service.dart +++ b/lib/utilities/desktop_password_service.dart @@ -60,13 +60,23 @@ class DPS { } try { - _handler = await StorageCryptoHandler.fromNewPassphrase( + if (await _get(key: _kKeyBlobKey) != null) { + throw Exception( + "DPS: attempted to overwrite an existing keyBlob with a new one", + ); + } + + final handler = await StorageCryptoHandler.fromNewPassphrase( passphrase, kLatestBlobVersion, ); + final keyBlob = await handler.getKeyBlob(); - await _put(key: _kKeyBlobKey, value: await _handler!.getKeyBlob()); + // The blob is the password-exists commit marker. Store its version first + // so a failed blob write leaves a safe, retryable version-only state. await _updateStoredKeyBlobVersion(kLatestBlobVersion); + await _putAndVerify(key: _kKeyBlobKey, value: keyBlob); + _handler = handler; } catch (e, s) { Logging.instance.e( "${_getMessageFromException(e)}\n$s", @@ -89,21 +99,36 @@ class DPS { if (keyBlob == null) { throw Exception( - "DPS: failed to find keyBlob while attempting to initialize with existing passphrase", + "DPS: failed to find keyBlob while attempting to initialize with" + " existing passphrase", ); } - final blobVersion = await _getStoredKeyBlobVersion(); - _handler = await StorageCryptoHandler.fromExisting( + final versionHint = await _getStoredKeyBlobVersion(); + final authenticated = await _authenticateKeyBlob( passphrase, keyBlob, - blobVersion, + versionHint, ); - if (blobVersion < kLatestBlobVersion) { - // update blob - await _handler!.resetPassphrase(passphrase, kLatestBlobVersion); - await _put(key: _kKeyBlobKey, value: await _handler!.getKeyBlob()); - await _updateStoredKeyBlobVersion(kLatestBlobVersion); + _handler = authenticated.handler; + + if (authenticated.version < kLatestBlobVersion) { + await _tryUpgradeKeyBlob( + passphrase: passphrase, + keyBlob: keyBlob, + version: authenticated.version, + ); + } else if (versionHint != authenticated.version) { + try { + await _updateStoredKeyBlobVersion(authenticated.version); + } catch (e, s) { + Logging.instance.w( + "DPS: failed to repair key blob version metadata", + error: e, + stackTrace: s, + ); + } } + await _tryCompactPasswordStorage(); } catch (e, s) { Logging.instance.e( "${_getMessageFromException(e)}\n$s", @@ -122,8 +147,8 @@ class DPS { // no passphrase key blob found so any passphrase is technically bad return false; } - final blobVersion = await _getStoredKeyBlobVersion(); - await StorageCryptoHandler.fromExisting(passphrase, keyBlob, blobVersion); + final versionHint = await _getStoredKeyBlobVersion(); + await _authenticateKeyBlob(passphrase, keyBlob, versionHint); // existing passphrase matches key blob return true; } catch (e, s) { @@ -142,6 +167,10 @@ class DPS { String passphraseNew, ) async { try { + if (_handler == null) { + return false; + } + final keyBlob = await _get(key: _kKeyBlobKey); if (keyBlob == null) { @@ -149,14 +178,22 @@ class DPS { return false; } - if (!(await verifyPassphrase(passphraseOld))) { - return false; - } + final versionHint = await _getStoredKeyBlobVersion(); + final authenticated = await _authenticateKeyBlob( + passphraseOld, + keyBlob, + versionHint, + ); + final newHandler = authenticated.handler; + await newHandler.resetPassphrase(passphraseNew, kLatestBlobVersion); + final newBlob = await newHandler.getKeyBlob(); - final blobVersion = await _getStoredKeyBlobVersion(); - await _handler!.resetPassphrase(passphraseNew, blobVersion); - await _put(key: _kKeyBlobKey, value: await _handler!.getKeyBlob()); - await _updateStoredKeyBlobVersion(blobVersion); + // The version may be temporarily ahead if the blob write fails. Readers + // probe supported versions, so the old blob remains usable and retryable. + await _updateStoredKeyBlobVersion(kLatestBlobVersion); + await _putAndVerify(key: _kKeyBlobKey, value: newBlob); + _handler = newHandler; + await _tryCompactPasswordStorage(); // successfully updated passphrase return true; @@ -181,7 +218,118 @@ class DPS { } Future _updateStoredKeyBlobVersion(int version) async { - await _put(key: _kKeyBlobVersionKey, value: version.toString()); + await _putAndVerify(key: _kKeyBlobVersionKey, value: version.toString()); + } + + Future<({StorageCryptoHandler handler, int version})> _authenticateKeyBlob( + String passphrase, + String keyBlob, + int versionHint, + ) async { + Object? lastError; + StackTrace? lastStackTrace; + final versions = {versionHint}; + for (int version = kLatestBlobVersion; version >= 1; version--) { + versions.add(version); + } + + for (final version in versions) { + try { + return ( + handler: await StorageCryptoHandler.fromExisting( + passphrase, + keyBlob, + version, + ), + version: version, + ); + } on IncorrectPassphraseOrVersion catch (e, s) { + lastError = e; + lastStackTrace = s; + } on VersionError catch (e, s) { + lastError = e; + lastStackTrace = s; + } + } + + Error.throwWithStackTrace(lastError!, lastStackTrace!); + } + + Future _tryUpgradeKeyBlob({ + required String passphrase, + required String keyBlob, + required int version, + }) async { + try { + final upgradedHandler = await StorageCryptoHandler.fromExisting( + passphrase, + keyBlob, + version, + ); + await upgradedHandler.resetPassphrase(passphrase, kLatestBlobVersion); + final upgradedBlob = await upgradedHandler.getKeyBlob(); + + await _updateStoredKeyBlobVersion(kLatestBlobVersion); + await _putAndVerify(key: _kKeyBlobKey, value: upgradedBlob); + _handler = upgradedHandler; + } catch (e, s) { + Logging.instance.w( + "DPS: key blob upgrade failed; continuing with authenticated version", + error: e, + stackTrace: s, + ); + } + } + + Future _tryCompactPasswordStorage() async { + Box? box; + try { + box = await DB.instance.hive.openBox(kBoxNameDesktopData); + await box.compact(); + } catch (e, s) { + Logging.instance.w( + "DPS: failed to compact desktop password storage", + error: e, + stackTrace: s, + ); + } finally { + try { + await box?.close(); + } catch (e, s) { + Logging.instance.w( + "DPS: failed to close desktop password storage after compaction", + error: e, + stackTrace: s, + ); + } + } + } + + Future _putAndVerify({ + required String key, + required String value, + }) async { + try { + await _put(key: key, value: value); + } catch (e, s) { + try { + if (await _get(key: key) == value) { + Logging.instance.w( + "DPS: put($key) reported an error but persisted data was verified", + error: e, + stackTrace: s, + ); + return; + } + } catch (_) { + // Preserve the original write error below. + } + Error.throwWithStackTrace(e, s); + } + + if (await _get(key: key) != value) { + throw Exception("DPS: persisted value verification failed for $key"); + } } Future _put({required String key, required String value}) async { @@ -191,6 +339,7 @@ class DPS { await box.put(key, value); } catch (e, s) { Logging.instance.f("DPS failed put($key): ", error: e, stackTrace: s); + rethrow; } finally { await box?.close(); } @@ -204,6 +353,7 @@ class DPS { value = box.get(key); } catch (e, s) { Logging.instance.f("DPS failed get($key): ", error: e, stackTrace: s); + rethrow; } finally { await box?.close(); } diff --git a/lib/utilities/enums/fee_rate_type_enum.dart b/lib/utilities/enums/fee_rate_type_enum.dart index 0ad32f1f69..a4348351dd 100644 --- a/lib/utilities/enums/fee_rate_type_enum.dart +++ b/lib/utilities/enums/fee_rate_type_enum.dart @@ -11,6 +11,10 @@ enum FeeRateType { fast, average, slow, custom } extension FeeRateTypeExt on FeeRateType { + bool get isCustom => this == FeeRateType.custom; + + int? customSatsPerVByte(int satsPerVByte) => isCustom ? satsPerVByte : null; + String get prettyName { switch (this) { case FeeRateType.fast: diff --git a/lib/utilities/extra_id_currency_support.dart b/lib/utilities/extra_id_currency_support.dart new file mode 100644 index 0000000000..6e5a78f8aa --- /dev/null +++ b/lib/utilities/extra_id_currency_support.dart @@ -0,0 +1,25 @@ +/* + * This file is part of Stack Wallet. + * + * Copyright (c) 2026 Cypher Stack + * All Rights Reserved. + * The code is distributed under GPLv3 license, see LICENSE file for details. + * + */ + +/// Currencies whose custodial deposits commonly require a destination +/// tag/memo ("extra ID") attached to the payout transaction. A payout sent +/// to such a platform without its tag lands unattributed. +abstract final class ExtraIdCurrencySupport { + static const Set _tickers = { + "atom", + "eos", + "hbar", + "ton", + "xlm", + "xrp", + }; + + static bool mayRequire(String ticker) => + _tickers.contains(ticker.trim().toLowerCase()); +} diff --git a/lib/utilities/integer_input.dart b/lib/utilities/integer_input.dart new file mode 100644 index 0000000000..6aaeae6e88 --- /dev/null +++ b/lib/utilities/integer_input.dart @@ -0,0 +1,32 @@ +final _decimalIntegerPattern = RegExp(r'^-?[0-9]+$'); + +int? tryParseIntegerInput(String text, {int? minimum, int? maximum}) { + assert(minimum == null || maximum == null || minimum <= maximum); + + final normalized = text.trim(); + if (!_decimalIntegerPattern.hasMatch(normalized)) { + return null; + } + + final value = int.tryParse(normalized, radix: 10); + if (value == null || + (minimum != null && value < minimum) || + (maximum != null && value > maximum)) { + return null; + } + + return value; +} + +({bool isValid, int? value}) parseOptionalIntegerInput( + String text, { + int? minimum, + int? maximum, +}) { + if (text.isEmpty) { + return (isValid: true, value: null); + } + + final value = tryParseIntegerInput(text, minimum: minimum, maximum: maximum); + return (isValid: value != null, value: value); +} diff --git a/lib/utilities/node_uri_util.dart b/lib/utilities/node_uri_util.dart index 73876949e8..b64c45367e 100644 --- a/lib/utilities/node_uri_util.dart +++ b/lib/utilities/node_uri_util.dart @@ -1,3 +1,5 @@ +bool isValidNodePort(int? port) => port != null && port > 0 && port <= 65535; + abstract interface class NodeQrData { final String host; final int port; @@ -109,6 +111,7 @@ abstract final class NodeQrUtil { switch (uri.scheme) { case "xmrrpc": + if (!uri.hasPort) throw Exception("Uri has no port."); return MoneroNodeQrData( host: uri.host, port: uri.port, @@ -117,6 +120,7 @@ abstract final class NodeQrUtil { label: query["label"], ); case "wowrpc": + if (!uri.hasPort) throw Exception("Uri has no port."); return WowneroNodeQrData( host: uri.host, port: uri.port, diff --git a/lib/utilities/util.dart b/lib/utilities/util.dart index c722832ef2..ec16286bf5 100644 --- a/lib/utilities/util.dart +++ b/lib/utilities/util.dart @@ -28,15 +28,59 @@ abstract class Util { static const isArmLinux = bool.fromEnvironment("IS_ARM"); static final isTestEnv = Platform.environment["FLUTTER_TEST"] == "true"; + static final Map _numberSymbolsCache = {}; + static Directory? libraryPath; static double? screenWidth; static NumberSymbols? getSymbolsFor({required String locale}) { - return numberFormatSymbols[locale] as NumberSymbols? ?? - numberFormatSymbols[locale.replaceAll("-", "_")] as NumberSymbols? ?? - numberFormatSymbols[locale.substring(3).toLowerCase()] - as NumberSymbols? ?? - numberFormatSymbols[locale.substring(0, 2)] as NumberSymbols?; + return _numberSymbolsCache.putIfAbsent(locale, () { + final exactSymbols = numberFormatSymbols[locale]; + if (exactSymbols is NumberSymbols) { + return exactSymbols; + } + + final localeParts = locale + .replaceAll("-", "_") + .split("_") + .where((part) => part.isNotEmpty) + .toList(); + if (localeParts.isEmpty) { + return null; + } + + final languageCode = localeParts.first.toLowerCase(); + String? scriptCode; + String? regionCode; + for (final part in localeParts.skip(1)) { + if (scriptCode == null && RegExp(r'^[A-Za-z]{4}$').hasMatch(part)) { + scriptCode = + "${part[0].toUpperCase()}${part.substring(1).toLowerCase()}"; + } else if (regionCode == null && + RegExp(r'^(?:[A-Za-z]{2}|\d{3})$').hasMatch(part)) { + regionCode = part.toUpperCase(); + } + } + + final candidates = { + [ + languageCode, + if (scriptCode != null) scriptCode, + if (regionCode != null) regionCode, + ].join("_"), + if (regionCode != null) "${languageCode}_$regionCode", + if (scriptCode != null) "${languageCode}_$scriptCode", + languageCode, + }; + + for (final candidate in candidates) { + final symbols = numberFormatSymbols[candidate]; + if (symbols is NumberSymbols) { + return symbols; + } + } + return null; + }); } static bool get isDesktop { diff --git a/lib/wallets/crypto_currency/coins/dash.dart b/lib/wallets/crypto_currency/coins/dash.dart index e2ad041eaf..4c3f3d81c1 100644 --- a/lib/wallets/crypto_currency/coins/dash.dart +++ b/lib/wallets/crypto_currency/coins/dash.dart @@ -90,7 +90,7 @@ class Dash extends Bip39HDCurrency with ElectrumXCurrencyInterface { @override Amount get dustLimit => - Amount(rawValue: BigInt.from(1000000), fractionDigits: fractionDigits); + Amount(rawValue: BigInt.from(546), fractionDigits: fractionDigits); @override String get genesisHash { diff --git a/lib/wallets/crypto_currency/coins/firo.dart b/lib/wallets/crypto_currency/coins/firo.dart index 583dc4b8dc..fb470c6898 100644 --- a/lib/wallets/crypto_currency/coins/firo.dart +++ b/lib/wallets/crypto_currency/coins/firo.dart @@ -13,6 +13,11 @@ import '../crypto_currency.dart'; import '../interfaces/electrumx_currency_interface.dart'; import '../intermediate/bip39_hd_currency.dart'; +bool _isBitcoinBech32Address(String address) { + final value = address.toLowerCase(); + return value.startsWith("bc1") || value.startsWith("tb1"); +} + class Firo extends Bip39HDCurrency with ElectrumXCurrencyInterface { Firo(super.network) { _idMain = "firo"; @@ -106,7 +111,7 @@ class Firo extends Bip39HDCurrency with ElectrumXCurrencyInterface { p2shPrefix: 0x07, privHDPrefix: 0x0488ade4, pubHDPrefix: 0x0488b21e, - bech32Hrp: "bc", + bech32Hrp: "", messagePrefix: '\x16Zcoin Signed Message:\n', minFee: BigInt.from(1), // Not used in stack wallet currently minOutput: dustLimit.raw, // Not used in stack wallet currently @@ -119,7 +124,7 @@ class Firo extends Bip39HDCurrency with ElectrumXCurrencyInterface { p2shPrefix: 0xb2, privHDPrefix: 0x04358394, pubHDPrefix: 0x043587cf, - bech32Hrp: "tb", + bech32Hrp: "", messagePrefix: "\x16Zcoin Signed Message:\n", minFee: BigInt.from(1), // Not used in stack wallet currently minOutput: dustLimit.raw, // Not used in stack wallet currently @@ -188,11 +193,8 @@ class Firo extends Bip39HDCurrency with ElectrumXCurrencyInterface { coinlib.Address.fromString(address, networkParams); return true; } catch (_) { - if (validateSparkAddress(address)) { - return true; - } else { - return isExchangeAddress(address); - } + if (_isBitcoinBech32Address(address)) return false; + return isExchangeAddress(address) || validateSparkAddress(address); } } @@ -301,9 +303,9 @@ class Firo extends Bip39HDCurrency with ElectrumXCurrencyInterface { @override AddressType? getAddressType(String address) { - if (validateSparkAddress(address)) { - return .spark; - } - return super.getAddressType(address); + if (_isBitcoinBech32Address(address)) return null; + final type = super.getAddressType(address); + if (type != null) return type; + return validateSparkAddress(address) ? .spark : null; } } diff --git a/lib/wallets/crypto_currency/coins/litecoin.dart b/lib/wallets/crypto_currency/coins/litecoin.dart index 1b830c4f96..0d3ce6f89e 100644 --- a/lib/wallets/crypto_currency/coins/litecoin.dart +++ b/lib/wallets/crypto_currency/coins/litecoin.dart @@ -51,6 +51,9 @@ class Litecoin extends Bip39HDCurrency with ElectrumXCurrencyInterface { // change this to change the number of confirms a tx needs in order to show as confirmed int get minConfirms => 1; + @override + int get mwebPegoutMaturity => 6; + @override bool get torSupport => true; @@ -169,11 +172,10 @@ class Litecoin extends Bip39HDCurrency with ElectrumXCurrencyInterface { return (address: addr, addressType: AddressType.p2pkh); case DerivePathType.bip49: - final p2wpkhScript = - coinlib.P2WPKHAddress.fromPublicKey( - publicKey, - hrp: networkParams.bech32Hrp, - ).program.script; + final p2wpkhScript = coinlib.P2WPKHAddress.fromPublicKey( + publicKey, + hrp: networkParams.bech32Hrp, + ).program.script; final addr = coinlib.P2SHAddress.fromRedeemScript( p2wpkhScript, diff --git a/lib/wallets/crypto_currency/coins/peercoin.dart b/lib/wallets/crypto_currency/coins/peercoin.dart index 0515beb4c3..a3241e2c94 100644 --- a/lib/wallets/crypto_currency/coins/peercoin.dart +++ b/lib/wallets/crypto_currency/coins/peercoin.dart @@ -165,11 +165,10 @@ class Peercoin extends Bip39HDCurrency with ElectrumXCurrencyInterface { return (address: addr, addressType: AddressType.p2pkh); case DerivePathType.bip49: - final p2wpkhScript = - coinlib.P2WPKHAddress.fromPublicKey( - publicKey, - hrp: networkParams.bech32Hrp, - ).program.script; + final p2wpkhScript = coinlib.P2WPKHAddress.fromPublicKey( + publicKey, + hrp: networkParams.bech32Hrp, + ).program.script; final addr = coinlib.P2SHAddress.fromRedeemScript( p2wpkhScript, @@ -266,5 +265,5 @@ class Peercoin extends Bip39HDCurrency with ElectrumXCurrencyInterface { int get transactionVersion => 3; @override - BigInt get defaultFeeRate => BigInt.from(5000); + BigInt get defaultFeeRate => BigInt.from(10000); } diff --git a/lib/wallets/crypto_currency/coins/xelis.dart b/lib/wallets/crypto_currency/coins/xelis.dart index d022082021..2d946bca62 100644 --- a/lib/wallets/crypto_currency/coins/xelis.dart +++ b/lib/wallets/crypto_currency/coins/xelis.dart @@ -83,7 +83,7 @@ class Xelis extends ElectrumCurrency { isPrimary: isPrimary, ); - case CryptoCurrencyNetwork.test: + case CryptoCurrencyNetwork.stage: return NodeModel( host: "stagenet-node.xelis.io", port: 443, diff --git a/lib/wallets/crypto_currency/crypto_currency.dart b/lib/wallets/crypto_currency/crypto_currency.dart index 8d02b130c0..16bba3ff7b 100644 --- a/lib/wallets/crypto_currency/crypto_currency.dart +++ b/lib/wallets/crypto_currency/crypto_currency.dart @@ -11,11 +11,11 @@ export 'coins/dash.dart'; export 'coins/dogecoin.dart'; export 'coins/ecash.dart'; export 'coins/epiccash.dart'; -export 'coins/mimblewimblecoin.dart'; export 'coins/ethereum.dart'; export 'coins/fact0rn.dart'; export 'coins/firo.dart'; export 'coins/litecoin.dart'; +export 'coins/mimblewimblecoin.dart'; export 'coins/monero.dart'; export 'coins/namecoin.dart'; export 'coins/nano.dart'; @@ -65,6 +65,7 @@ abstract class CryptoCurrency { int get minConfirms; int get minCoinbaseConfirms => minConfirms; + int? get mwebPegoutMaturity => null; // TODO: [prio=low] could be handled differently as (at least) epiccash/mimblewimblecoin does not use this String get genesisHash; diff --git a/lib/wallets/models/tx_data.dart b/lib/wallets/models/tx_data.dart index 744d848107..1c8f81c931 100644 --- a/lib/wallets/models/tx_data.dart +++ b/lib/wallets/models/tx_data.dart @@ -105,6 +105,7 @@ class TxData { final TransactionV2? tempTx; final bool ignoreCachedBalanceChecks; + final bool subtractFeeFromAmount; // Namecoin Name related final NameOpState? opNameState; @@ -150,6 +151,7 @@ class TxData { this.usedSparkCoins, this.tempTx, this.ignoreCachedBalanceChecks = false, + this.subtractFeeFromAmount = false, this.opNameState, this.sparkNameInfo, this.vExtraData, @@ -298,6 +300,7 @@ class TxData { List? usedSparkCoins, TransactionV2? tempTx, bool? ignoreCachedBalanceChecks, + bool? subtractFeeFromAmount, NameOpState? opNameState, ({ String additionalInfo, @@ -346,6 +349,8 @@ class TxData { tempTx: tempTx ?? this.tempTx, ignoreCachedBalanceChecks: ignoreCachedBalanceChecks ?? this.ignoreCachedBalanceChecks, + subtractFeeFromAmount: + subtractFeeFromAmount ?? this.subtractFeeFromAmount, opNameState: opNameState ?? this.opNameState, sparkNameInfo: sparkNameInfo ?? this.sparkNameInfo, vExtraData: vExtraData ?? this.vExtraData, @@ -390,6 +395,7 @@ class TxData { 'otherData: $otherData, ' 'tempTx: $tempTx, ' 'ignoreCachedBalanceChecks: $ignoreCachedBalanceChecks, ' + 'subtractFeeFromAmount: $subtractFeeFromAmount, ' 'opNameState: $opNameState, ' 'sparkNameInfo: $sparkNameInfo, ' 'vExtraData: ${vExtraData?.toHex}, ' diff --git a/lib/wallets/wallet/impl/bitcoin_frost_wallet.dart b/lib/wallets/wallet/impl/bitcoin_frost_wallet.dart index 00537b12b8..aa34ffa3b9 100644 --- a/lib/wallets/wallet/impl/bitcoin_frost_wallet.dart +++ b/lib/wallets/wallet/impl/bitcoin_frost_wallet.dart @@ -22,6 +22,7 @@ import '../../../services/event_bus/events/global/wallet_sync_status_changed_eve import '../../../services/event_bus/global_event_bus.dart'; import '../../../utilities/amount/amount.dart'; import '../../../utilities/extensions/extensions.dart'; +import '../../../utilities/flutter_secure_storage_interface.dart'; import '../../../utilities/logger.dart'; import '../../../wl_gen/interfaces/frost_interface.dart'; import '../../crypto_currency/crypto_currency.dart'; @@ -125,6 +126,7 @@ class BitcoinFrostWallet extends Wallet .getUTXOs(walletId) .filter() .isBlockedEqualTo(false) + .group((q) => q.usedEqualTo(false).or().usedIsNull()) .findAll(); if (utxos.isEmpty) { @@ -1111,6 +1113,22 @@ class BitcoinFrostWallet extends Wallet // =================== Secure storage ======================================== + static Future deleteSecureStorage({ + required String walletId, + required SecureStorageInterface secureStorage, + }) async { + for (final suffix in const [ + 'serializedFROSTKeys', + 'serializedFROSTKeysPrevGen', + 'multisigConfig', + 'multisigConfigPrevGen', + 'multisigIdFROST', + 'recoveryStringFROST', + ]) { + await secureStorage.delete(key: '{$walletId}_$suffix'); + } + } + Future getSerializedKeys() async => await secureStorageInterface.read(key: "{$walletId}_serializedFROSTKeys"); diff --git a/lib/wallets/wallet/impl/epiccash_wallet.dart b/lib/wallets/wallet/impl/epiccash_wallet.dart index 3746a4836e..3e5af8a423 100644 --- a/lib/wallets/wallet/impl/epiccash_wallet.dart +++ b/lib/wallets/wallet/impl/epiccash_wallet.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'dart:io'; import 'package:decimal/decimal.dart'; +import 'package:flutter/foundation.dart'; import 'package:isar_community/isar.dart'; import 'package:mutex/mutex.dart'; import 'package:stack_wallet_backup/generate_password.dart'; @@ -40,6 +41,7 @@ import '../../crypto_currency/crypto_currency.dart'; import '../../models/tx_data.dart'; import '../intermediate/bip39_wallet.dart'; import '../supporting/epiccash_wallet_info_extension.dart'; +import '../supporting/restore_progress.dart'; // // refactor of https://github.com/cypherstack/stack_wallet/blob/1d9fb4cd069f22492ece690ac788e05b8f8b1209/lib/services/coins/epiccash/epiccash_wallet.dart @@ -57,7 +59,10 @@ class EpiccashWallet extends Bip39Wallet { Future get getSyncPercent async { final int lastScannedBlock = info.epicData?.lastScannedBlock ?? 0; final _chainHeight = await chainHeight; - final double restorePercent = lastScannedBlock / _chainHeight; + final restorePercent = calculateRestoreProgress( + scannedHeight: lastScannedBlock, + chainHeight: _chainHeight, + ); GlobalEventBus.instance.fire( RefreshPercentChangedEvent(highestPercent, walletId), ); @@ -931,18 +936,22 @@ class EpiccashWallet extends Bip39Wallet { return await super.init(); } + @visibleForTesting + bool shouldCheckEpicbox(String receiverAddress) => + !isHttpAddress(receiverAddress); + @override Future confirmSend({required TxData txData}) async { try { _hackedCheckTorNodePrefs(); - final EpicBoxConfigModel epicboxConfig = await getEpicBoxConfig(); // TODO determine whether it is worth sending change to a change address. final String receiverAddress = txData.recipients!.first.address; + final useEpicbox = shouldCheckEpicbox(receiverAddress); - if (!receiverAddress.startsWith("http://") || - !receiverAddress.startsWith("https://")) { + if (useEpicbox) { + final epicboxConfig = await getEpicBoxConfig(); final bool isEpicboxConnected = await _testEpicboxServer(epicboxConfig); if (!isEpicboxConnected) { throw Exception("Failed to send TX : Unable to reach epicbox server"); @@ -951,8 +960,7 @@ class EpiccashWallet extends Bip39Wallet { ({String commitId, String slateId, String slateJson}) transaction; - if (receiverAddress.startsWith("http://") || - receiverAddress.startsWith("https://")) { + if (!useEpicbox) { final httpResult = await libEpic.txHttpSend( wallet: _wallet!, selectionStrategyIsAll: 0, diff --git a/lib/wallets/wallet/impl/ethereum_wallet.dart b/lib/wallets/wallet/impl/ethereum_wallet.dart index 354d7fea55..f5444c7428 100644 --- a/lib/wallets/wallet/impl/ethereum_wallet.dart +++ b/lib/wallets/wallet/impl/ethereum_wallet.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'package:decimal/decimal.dart'; import 'package:ethereum_addresses/ethereum_addresses.dart'; +import 'package:flutter/foundation.dart'; import 'package:http/http.dart'; import 'package:isar_community/isar.dart'; import 'package:wallet/wallet.dart' as eth_wallet; @@ -28,8 +29,98 @@ import '../../models/tx_data.dart'; import '../intermediate/bip39_wallet.dart'; import '../wallet_mixin_interfaces/private_key_interface.dart'; +@visibleForTesting +Future> findReplacedPendingEthereumTransactions({ + required String walletId, + required Iterable transactions, + required Future Function() getLatestConfirmedNonce, + required Future Function(String txid) + getTransactionByHash, + void Function(Object error, StackTrace stackTrace)? onNonceLookupError, + void Function(TransactionV2 transaction, Object error, StackTrace stackTrace)? + onTransactionLookupError, +}) async { + final candidates = transactions + .where( + (transaction) => + transaction.walletId == walletId && + transaction.height == null && + transaction.blockHash == null && + transaction.nonce != null && + (transaction.type == TransactionType.outgoing || + transaction.type == TransactionType.sentToSelf) && + (transaction.subType == TransactionSubType.none || + transaction.subType == TransactionSubType.ethToken), + ) + .toList(growable: false); + + if (candidates.isEmpty) { + return const []; + } + + final int latestConfirmedNonce; + try { + latestConfirmedNonce = await getLatestConfirmedNonce(); + } catch (e, s) { + onNonceLookupError?.call(e, s); + return const []; + } + + final replacedTransactions = []; + for (final transaction in candidates) { + if (transaction.nonce! >= latestConfirmedNonce) { + continue; + } + + try { + final originalTransaction = await getTransactionByHash(transaction.txid); + if (originalTransaction?.blockHash == null) { + replacedTransactions.add(transaction); + } + } catch (e, s) { + onTransactionLookupError?.call(transaction, e, s); + } + } + + return replacedTransactions; +} + // Eth can not use tor with web3dart +@visibleForTesting +({BigInt maxFeePerGas, BigInt maxPriorityFeePerGas}) resolveEip1559FeeCaps({ + required BigInt baseFee, + required BigInt priorityFeePerGas, + BigInt? customMaxFeePerGas, +}) { + if (customMaxFeePerGas != null && priorityFeePerGas.isNegative) { + throw Exception("Max priority fee per gas cannot be negative."); + } + final maxPriorityFeePerGas = priorityFeePerGas.isNegative + ? BigInt.zero + : priorityFeePerGas; + + // Presets get 2x base-fee headroom since it can rise 12.5% per block. The + // EIP-1559 max fee is the total per-gas cap, so it also includes priority. + final maxFeePerGas = + customMaxFeePerGas ?? baseFee * BigInt.two + maxPriorityFeePerGas; + + if (maxFeePerGas <= BigInt.zero) { + throw Exception("Max fee per gas must be greater than zero."); + } + if (baseFee > maxFeePerGas) { + throw Exception("Max fee per gas is below the current network base fee."); + } + if (maxPriorityFeePerGas > maxFeePerGas) { + throw Exception("Max priority fee per gas exceeds max fee per gas."); + } + + return ( + maxFeePerGas: maxFeePerGas, + maxPriorityFeePerGas: maxPriorityFeePerGas, + ); +} + class EthereumWallet extends Bip39Wallet with PrivateKeyInterface { EthereumWallet(CryptoCurrencyNetwork network) : super(Ethereum(network)); @@ -304,6 +395,7 @@ class EthereumWallet extends Bip39Wallet with PrivateKeyInterface { if (response.value!.isEmpty) { // no new transactions found + await deleteReplacedPendingTransactions(); return; } @@ -422,6 +514,55 @@ class EthereumWallet extends Bip39Wallet with PrivateKeyInterface { txns.add(txn); } await mainDB.updateOrPutTransactionV2s(txns); + await deleteReplacedPendingTransactions(); + } + + Future deleteReplacedPendingTransactions() async { + final pendingTransactions = await mainDB.isar.transactionV2s + .where() + .walletIdEqualTo(walletId) + .filter() + .heightIsNull() + .nonceIsNotNull() + .findAll(); + + web3.Web3Client? client; + web3.Web3Client getClient() => client ??= getEthClient(); + final replacedTransactions = await findReplacedPendingEthereumTransactions( + walletId: walletId, + transactions: pendingTransactions, + getLatestConfirmedNonce: () async => getClient().getTransactionCount( + await getMyWeb3Address(), + atBlock: const web3.BlockNum.current(), + ), + getTransactionByHash: (txid) => getClient().getTransactionByHash(txid), + onNonceLookupError: (error, stackTrace) { + Logging.instance.w( + "$runtimeType failed to get the latest confirmed nonce", + error: error, + stackTrace: stackTrace, + ); + }, + onTransactionLookupError: (transaction, error, stackTrace) { + Logging.instance.w( + "$runtimeType failed to look up pending transaction " + "${transaction.txid}", + error: error, + stackTrace: stackTrace, + ); + }, + ); + final replacedTransactionIds = replacedTransactions + .map((transaction) => transaction.id) + .toList(growable: false); + + if (replacedTransactionIds.isEmpty) { + return; + } + + await mainDB.isar.writeTxn(() async { + await mainDB.isar.transactionV2s.deleteAll(replacedTransactionIds); + }); } @override @@ -440,9 +581,8 @@ class EthereumWallet extends Bip39Wallet with PrivateKeyInterface { ({ int nonce, BigInt chainId, - BigInt baseFee, - BigInt maxBaseFee, - BigInt priorityFee, + BigInt maxFeePerGas, + BigInt maxPriorityFeePerGas, }) > internalSharedPrepareSend({ @@ -469,31 +609,25 @@ class EthereumWallet extends Bip39Wallet with PrivateKeyInterface { final feeObject = await fees; final BigInt baseFee = feeObject.suggestBaseFee; - // Presets get 2x headroom since base fee can rise 12.5% per block. - final BigInt maxBaseFee = feeRateType == .custom - ? txData.ethEIP1559Fee!.maxBaseFeeWei - : baseFee * BigInt.two; - final BigInt rawPriority = switch (feeRateType) { .fast => feeObject.fast - baseFee, .average => feeObject.medium - baseFee, .slow => feeObject.slow - baseFee, - .custom => txData.ethEIP1559Fee!.priorityFeeWei, + .custom => txData.ethEIP1559Fee!.maxPriorityFeePerGasWei, }; - final BigInt priorityFee = rawPriority.isNegative - ? BigInt.zero - : rawPriority; - - if (baseFee > maxBaseFee) { - throw Exception("Max base fee is below the current network base fee."); - } + final feeCaps = resolveEip1559FeeCaps( + baseFee: baseFee, + priorityFeePerGas: rawPriority, + customMaxFeePerGas: feeRateType == .custom + ? txData.ethEIP1559Fee!.maxFeePerGasWei + : null, + ); return ( nonce: nonce, chainId: chainId, - baseFee: baseFee, - maxBaseFee: maxBaseFee, - priorityFee: priorityFee, + maxFeePerGas: feeCaps.maxFeePerGas, + maxPriorityFeePerGas: feeCaps.maxPriorityFeePerGas, ); } @@ -515,24 +649,26 @@ class EthereumWallet extends Bip39Wallet with PrivateKeyInterface { throw Exception("Insufficient balance"); } + final gasLimit = txData.ethEIP1559Fee?.gasLimit ?? kEthereumMinGasLimit; final tx = web3.Transaction( to: eth_wallet.EthereumAddress.fromHex(address), - maxGas: txData.ethEIP1559Fee?.gasLimit ?? kEthereumMinGasLimit, + maxGas: gasLimit, value: eth_wallet.EtherAmount.inWei(amount.raw), nonce: prep.nonce, maxFeePerGas: eth_wallet.EtherAmount.fromBigInt( eth_wallet.EtherUnit.wei, - prep.maxBaseFee, + prep.maxFeePerGas, ), maxPriorityFeePerGas: eth_wallet.EtherAmount.fromBigInt( eth_wallet.EtherUnit.wei, - prep.priorityFee, + prep.maxPriorityFeePerGas, ), ); - final feeEstimate = await estimateFeeFor( - Amount.zero, - prep.maxBaseFee + prep.priorityFee, + final feeEstimate = estimateEthFee( + prep.maxFeePerGas, + gasLimit, + cryptoCurrency.fractionDigits, ); return txData.copyWith( diff --git a/lib/wallets/wallet/impl/mimblewimblecoin_wallet.dart b/lib/wallets/wallet/impl/mimblewimblecoin_wallet.dart index d31be377f1..6a65be7153 100644 --- a/lib/wallets/wallet/impl/mimblewimblecoin_wallet.dart +++ b/lib/wallets/wallet/impl/mimblewimblecoin_wallet.dart @@ -35,6 +35,7 @@ import '../../crypto_currency/crypto_currency.dart'; import '../../models/tx_data.dart'; import '../intermediate/bip39_wallet.dart'; import '../supporting/mimblewimblecoin_wallet_info_extension.dart'; +import '../supporting/restore_progress.dart'; class MimblewimblecoinWallet extends Bip39Wallet { MimblewimblecoinWallet(CryptoCurrencyNetwork network) @@ -55,7 +56,10 @@ class MimblewimblecoinWallet extends Bip39Wallet { final int lastScannedBlock = info.mimblewimblecoinData?.lastScannedBlock ?? 0; final _chainHeight = await chainHeight; - final double restorePercent = lastScannedBlock / _chainHeight; + final restorePercent = calculateRestoreProgress( + scannedHeight: lastScannedBlock, + chainHeight: _chainHeight, + ); GlobalEventBus.instance.fire( RefreshPercentChangedEvent(highestPercent, walletId), ); @@ -834,7 +838,7 @@ class MimblewimblecoinWallet extends Bip39Wallet { int _calculateRestoreHeightFrom({required DateTime date}) { final int secondsSinceEpoch = date.millisecondsSinceEpoch ~/ 1000; - const int mimblewimblecoinFirstBlock = 1565370278; + const int mimblewimblecoinFirstBlock = 1573462800; const double overestimateSecondsPerBlock = 61; final int chosenSeconds = secondsSinceEpoch - mimblewimblecoinFirstBlock; final int approximateHeight = chosenSeconds ~/ overestimateSecondsPerBlock; diff --git a/lib/wallets/wallet/impl/sub_wallets/eth_token_wallet.dart b/lib/wallets/wallet/impl/sub_wallets/eth_token_wallet.dart index 6aca5a0082..1c61f297b4 100644 --- a/lib/wallets/wallet/impl/sub_wallets/eth_token_wallet.dart +++ b/lib/wallets/wallet/impl/sub_wallets/eth_token_wallet.dart @@ -240,25 +240,28 @@ class EthTokenWallet extends Wallet { throw Exception("Insufficient balance"); } + final gasLimit = + txData.ethEIP1559Fee?.gasLimit ?? kEthereumTokenMinGasLimit; final tx = web3dart.Transaction.callContract( contract: _deployedContract, function: _sendFunction, parameters: [eth_wallet.EthereumAddress.fromHex(address), amount.raw], - maxGas: txData.ethEIP1559Fee?.gasLimit ?? kEthereumTokenMinGasLimit, + maxGas: gasLimit, nonce: prep.nonce, maxFeePerGas: eth_wallet.EtherAmount.fromBigInt( eth_wallet.EtherUnit.wei, - prep.maxBaseFee, + prep.maxFeePerGas, ), maxPriorityFeePerGas: eth_wallet.EtherAmount.fromBigInt( eth_wallet.EtherUnit.wei, - prep.priorityFee, + prep.maxPriorityFeePerGas, ), ); - final feeEstimate = await estimateFeeFor( - Amount.zero, - prep.maxBaseFee + prep.priorityFee, + final feeEstimate = ethWallet.estimateEthFee( + prep.maxFeePerGas, + gasLimit, + cryptoCurrency.fractionDigits, ); return txData.copyWith( fee: feeEstimate, @@ -385,6 +388,7 @@ class EthTokenWallet extends Wallet { // no need to continue if no transactions found if (response.value!.isEmpty) { + await ethWallet.deleteReplacedPendingTransactions(); return; } @@ -506,6 +510,7 @@ class EthTokenWallet extends Wallet { } } await mainDB.updateOrPutTransactionV2s(txns); + await ethWallet.deleteReplacedPendingTransactions(); } catch (e, s) { Logging.instance.w( "$runtimeType wallet failed to update transactions: ", diff --git a/lib/wallets/wallet/impl/xelis_wallet.dart b/lib/wallets/wallet/impl/xelis_wallet.dart index 66b551bb8e..fa5579b5ba 100644 --- a/lib/wallets/wallet/impl/xelis_wallet.dart +++ b/lib/wallets/wallet/impl/xelis_wallet.dart @@ -22,6 +22,7 @@ import '../../../wl_gen/interfaces/lib_xelis_interface.dart'; import '../../crypto_currency/crypto_currency.dart'; import '../../models/tx_data.dart'; import '../intermediate/lib_xelis_wallet.dart'; +import '../intermediate/xelis_event_batcher.dart'; import '../wallet.dart'; class XelisWallet extends LibXelisWallet { @@ -200,7 +201,7 @@ class XelisWallet extends LibXelisWallet { @override Future recover({required bool isRescan}) async { if (isRescan) { - await refreshMutex.protect(() async { + await runXelisRescan(() async { await mainDB.deleteWalletBlockchainData(walletId); await updateTransactions(isRescan: true, topoheight: 0); }); @@ -229,10 +230,8 @@ class XelisWallet extends LibXelisWallet { Future pingCheck() async { try { await libXelis.getDaemonInfo(wallet!); - await handleOnline(); return true; } catch (_) { - await handleOffline(); return false; } } @@ -240,7 +239,10 @@ class XelisWallet extends LibXelisWallet { final _balanceUpdateMutex = Mutex(); @override - Future updateBalance({int? newBalance}) async { + Future updateBalance({ + int? newBalance, + bool rethrowErrors = false, + }) async { await _balanceUpdateMutex.protect(() async { try { if (await libXelis.hasXelisBalance(wallet!)) { @@ -273,6 +275,9 @@ class XelisWallet extends LibXelisWallet { error: e, stackTrace: s, ); + if (rethrowErrors) { + rethrow; + } } }); } @@ -288,7 +293,10 @@ class XelisWallet extends LibXelisWallet { } @override - Future updateChainHeight({int? topoheight}) async { + Future updateChainHeight({ + int? topoheight, + bool rethrowErrors = false, + }) async { try { final height = topoheight ?? await _fetchChainHeight(); @@ -302,17 +310,16 @@ class XelisWallet extends LibXelisWallet { error: e, stackTrace: s, ); + if (rethrowErrors) { + rethrow; + } } } @override Future updateNode() async { try { - final bool online = await libXelis.isOnline(wallet!); - if (online == true) { - await libXelis.offlineMode(wallet!); - } - await super.connect(); + await connect(disconnectFirst: true); } catch (e, s) { Logging.instance.e( "Error rethrown from $runtimeType updateNode()", @@ -897,55 +904,52 @@ class XelisWallet extends LibXelisWallet { } @override - Future handleNewTopoHeight(int height) async { - await info.updateCachedChainHeight(newHeight: height, isar: mainDB.isar); - } + Future handleNewTopoHeight(int _) async => + eventBatcher.queueTopoheightChanged(); @override - Future handleNewTransaction(TransactionEntryWrapper tx) async { - try { - final txList = [tx]; - final newTxIds = await updateTransactions( - isRescan: false, - objTransactions: txList, - ); - - await updateBalance(); + Future handleNewTransaction(TransactionEntryWrapper tx) async => + eventBatcher.queueTransaction(tx); - // Logging.instance.log( - // "New transaction processed: ${newTxIds.first}", - // level: LogLevel.Info, - // ); - } catch (e, s) { - Logging.instance.e( - "Error in $runtimeType handleNewTransaction($tx)", - error: e, - stackTrace: s, - ); + @override + Future handleBalanceChanged(BalanceChanged event) async { + if (event.asset == libXelis.xelisAsset) { + eventBatcher.queueBalanceChanged(); } } @override - Future handleBalanceChanged(BalanceChanged event) async { + Future applyXelisEventBatch( + XelisEventBatch batch, + ) async { try { - final asset = event.asset; - if (asset == libXelis.xelisAsset) { - await updateBalance(newBalance: event.balance); + if (batch.topoheightChanged) { + await updateChainHeight(rethrowErrors: true); } - // TODO: Update asset balances if needed + if (batch.transactions.isNotEmpty) { + await updateTransactions( + isRescan: false, + objTransactions: batch.transactions, + ); + } + + if (batch.balanceChanged || batch.transactions.isNotEmpty) { + await updateBalance(rethrowErrors: true); + } } catch (e, s) { Logging.instance.e( - "Error in $runtimeType handleBalanceChanged($event)", + "Error in $runtimeType applyXelisEventBatch()", error: e, stackTrace: s, ); + unawaited(refresh()); } } @override Future handleRescan(int startTopoheight) async { - await refreshMutex.protect(() async { + await runXelisRescan(() async { await mainDB.deleteWalletBlockchainData(walletId); await updateTransactions(isRescan: true, topoheight: startTopoheight); await updateBalance(); @@ -953,19 +957,7 @@ class XelisWallet extends LibXelisWallet { } @override - Future handleOnline() async { - await updateChainHeight(); - await updateBalance(); - await updateTransactions(); - GlobalEventBus.instance.fire( - WalletSyncStatusChangedEvent( - WalletSyncStatus.synced, - walletId, - info.coin, - ), - ); - unawaited(refresh()); - } + Future handleOnline() => runXelisSyncEvent(); @override Future handleOffline() async { @@ -979,18 +971,7 @@ class XelisWallet extends LibXelisWallet { } @override - Future handleHistorySynced(int topoheight) async { - await updateChainHeight(); - await updateBalance(); - await updateTransactions(); - GlobalEventBus.instance.fire( - WalletSyncStatusChangedEvent( - WalletSyncStatus.synced, - walletId, - info.coin, - ), - ); - } + Future handleHistorySynced(int _) => runXelisSyncEvent(); @override Future handleNewAsset(NewAsset asset) async { @@ -1000,30 +981,59 @@ class XelisWallet extends LibXelisWallet { } @override - Future refresh({int? topoheight}) async { - await refreshMutex.protect(() async { - try { - final bool online = await libXelis.isOnline(wallet!); - if (online == true) { - await updateChainHeight(topoheight: topoheight); - await updateBalance(); - await updateTransactions(); - } else { + Future performXelisRefresh() async { + try { + final bool online = await libXelis.isOnline(wallet!); + if (online == true) { + if (!doNotFireRefreshEvents) { GlobalEventBus.instance.fire( WalletSyncStatusChangedEvent( - WalletSyncStatus.unableToSync, + WalletSyncStatus.syncing, walletId, info.coin, ), ); } - } catch (e, s) { - Logging.instance.e( - "Error in $runtimeType refresh()", - error: e, - stackTrace: s, + + await updateChainHeight(rethrowErrors: true); + await updateBalance(rethrowErrors: true); + await updateTransactions(); + + if (!doNotFireRefreshEvents) { + GlobalEventBus.instance.fire( + WalletSyncStatusChangedEvent( + WalletSyncStatus.synced, + walletId, + info.coin, + ), + ); + } + ensurePeriodicRefreshTimer(); + } else if (!doNotFireRefreshEvents) { + GlobalEventBus.instance.fire( + WalletSyncStatusChangedEvent( + WalletSyncStatus.unableToSync, + walletId, + info.coin, + ), ); } - }); + } catch (e, s) { + if (!doNotFireRefreshEvents) { + GlobalEventBus.instance.fire( + WalletSyncStatusChangedEvent( + WalletSyncStatus.unableToSync, + walletId, + info.coin, + ), + ); + } + Logging.instance.e( + "Error in $runtimeType performXelisRefresh()", + error: e, + stackTrace: s, + ); + rethrow; + } } } diff --git a/lib/wallets/wallet/intermediate/lib_xelis_wallet.dart b/lib/wallets/wallet/intermediate/lib_xelis_wallet.dart index eb818021a6..4938bea2b3 100644 --- a/lib/wallets/wallet/intermediate/lib_xelis_wallet.dart +++ b/lib/wallets/wallet/intermediate/lib_xelis_wallet.dart @@ -12,6 +12,8 @@ import '../../../wl_gen/interfaces/lib_xelis_interface.dart'; import '../../crypto_currency/intermediate/electrum_currency.dart'; import '../wallet_mixin_interfaces/mnemonic_interface.dart'; import 'external_wallet.dart'; +import 'xelis_event_batcher.dart'; +import 'xelis_operation_coordinator.dart'; abstract class LibXelisWallet extends ExternalWallet @@ -38,6 +40,18 @@ abstract class LibXelisWallet Timer? timer; StreamSubscription? _eventSubscription; + late final XelisOperationCoordinator _operationCoordinator = + XelisOperationCoordinator(refreshMutex); + + static const _eventFlushInterval = Duration(milliseconds: 500); + + @protected + late final XelisEventBatcher eventBatcher = + XelisEventBatcher( + flushInterval: _eventFlushInterval, + flush: (batch) => + runXelisEventUpdate(() => applyXelisEventBatch(batch)), + ); Future getPrecomputedTablesPath() async { if (kIsWeb) { @@ -88,31 +102,70 @@ abstract class LibXelisWallet Future handleHistorySynced(int topoheight) async {} Future handleNewAsset(NewAsset asset) async {} - @override - Future refresh({int? topoheight}); - - Future connect() async { - final node = getCurrentNode(); - try { - checkInitialized(); - _eventSubscription = libXelis.eventsStream(wallet!).listen(handleEvent); - - Logging.instance.i("Connecting to node: ${node.host}:${node.port}"); - await libXelis.onlineMode( - wallet!, - daemonAddress: "${node.host}:${node.port}", - ); - await super.refresh(); - } catch (e, s) { - Logging.instance.e( - "rethrowing error connecting to node: $node", - error: e, - stackTrace: s, - ); - rethrow; - } + @protected + Future performXelisRefresh(); + + @protected + Future applyXelisEventBatch( + XelisEventBatch batch, + ); + + @protected + Future runXelisRescan(Future Function() operation) { + eventBatcher.reset(); + return _operationCoordinator.rescan(operation); } + @protected + Future runXelisEventUpdate(Future Function() operation) => + _operationCoordinator.processEvent(operation); + + @protected + Future runXelisSyncEvent() => + _operationCoordinator.processSyncEvent(performXelisRefresh); + + // Intentionally swallow logged errors because refresh is often unawaited. + @override + Future refresh() => + _operationCoordinator.refresh(performXelisRefresh).catchError((_) {}); + + Future connect({bool disconnectFirst = false}) => + _operationCoordinator.connect(() async { + final node = getCurrentNode(); + try { + checkInitialized(); + + final wasOnline = await libXelis.isOnline(wallet!); + await _eventSubscription?.cancel(); + _eventSubscription = null; + + if (wasOnline && disconnectFirst) { + await libXelis.offlineMode(wallet!); + } + + _eventSubscription = libXelis.eventsStream(wallet!).listen((event) { + unawaited(handleEvent(event)); + }); + + if (!wasOnline || disconnectFirst) { + Logging.instance.i("Connecting to node: ${node.host}:${node.port}"); + await libXelis.onlineMode( + wallet!, + daemonAddress: "${node.host}:${node.port}", + ); + } + + await performXelisRefresh(); + } catch (e, s) { + Logging.instance.e( + "rethrowing error connecting to node: $node", + error: e, + stackTrace: s, + ); + rethrow; + } + }, joinExisting: !disconnectFirst); + List get standardReceivingAddressFilters => [ FilterCondition.equalTo(property: r"type", value: info.mainAddressType), const FilterCondition.equalTo( @@ -139,45 +192,22 @@ abstract class LibXelisWallet } @override - Future open() async { - while (exitInProgress) { - await Future.delayed(const Duration(milliseconds: 500)); - } + Future open() => connect(); - try { - await connect(); - } catch (e) { - // Logging.instance.log( - // "Failed to start sync: $e", - // level: LogLevel.Error, - // ); - rethrow; - } - unawaited(refresh()); - } + @override + Future exit() => _operationCoordinator.exit(() async { + timer?.cancel(); + timer = null; - bool exitInProgress = false; + eventBatcher.reset(); + await _eventSubscription?.cancel(); + _eventSubscription = null; - @override - Future exit() async { - exitInProgress = true; - try { - await refreshMutex.protect(() async { - timer?.cancel(); - timer = null; - - await _eventSubscription?.cancel(); - _eventSubscription = null; - - if (wallet != null && await libXelis.isOnline(wallet!)) { - await libXelis.offlineMode(wallet!); - } - await super.exit(); - }); - } finally { - exitInProgress = false; + if (wallet != null && await libXelis.isOnline(wallet!)) { + await libXelis.offlineMode(wallet!); } - } + await super.exit(); + }); void invalidSeedLengthCheck(int length) { if (!(length == 25)) { diff --git a/lib/wallets/wallet/intermediate/xelis_event_batcher.dart b/lib/wallets/wallet/intermediate/xelis_event_batcher.dart new file mode 100644 index 0000000000..031d7cdb94 --- /dev/null +++ b/lib/wallets/wallet/intermediate/xelis_event_batcher.dart @@ -0,0 +1,84 @@ +import 'dart:async'; + +final class XelisEventBatch { + const XelisEventBatch({ + required this.transactions, + required this.topoheightChanged, + required this.balanceChanged, + }); + + final List transactions; + final bool topoheightChanged; + final bool balanceChanged; + + bool get isEmpty => + transactions.isEmpty && !topoheightChanged && !balanceChanged; +} + +final class XelisEventBatcher { + XelisEventBatcher({required this.flushInterval, required this.flush}); + + final Duration flushInterval; + final Future Function(XelisEventBatch batch) flush; + + final List _transactions = []; + bool _topoheightChanged = false; + bool _balanceChanged = false; + bool _isFlushing = false; + Timer? _flushTimer; + + void queueTransaction(T transaction) { + _transactions.add(transaction); + _scheduleFlush(); + } + + void queueTopoheightChanged() { + _topoheightChanged = true; + _scheduleFlush(); + } + + void queueBalanceChanged() { + _balanceChanged = true; + _scheduleFlush(); + } + + void reset() { + _flushTimer?.cancel(); + _flushTimer = null; + _transactions.clear(); + _topoheightChanged = false; + _balanceChanged = false; + } + + void _scheduleFlush() { + if (!_isFlushing) { + _flushTimer ??= Timer(flushInterval, () => unawaited(_flushNow())); + } + } + + Future _flushNow() async { + _flushTimer = null; + final batch = XelisEventBatch( + transactions: List.of(_transactions), + topoheightChanged: _topoheightChanged, + balanceChanged: _balanceChanged, + ); + _transactions.clear(); + _topoheightChanged = false; + _balanceChanged = false; + + if (batch.isEmpty) { + return; + } + + _isFlushing = true; + try { + await flush(batch); + } finally { + _isFlushing = false; + if (_transactions.isNotEmpty || _topoheightChanged || _balanceChanged) { + _scheduleFlush(); + } + } + } +} diff --git a/lib/wallets/wallet/intermediate/xelis_operation_coordinator.dart b/lib/wallets/wallet/intermediate/xelis_operation_coordinator.dart new file mode 100644 index 0000000000..11ca003147 --- /dev/null +++ b/lib/wallets/wallet/intermediate/xelis_operation_coordinator.dart @@ -0,0 +1,145 @@ +import 'package:mutex/mutex.dart'; + +enum XelisOperation { + idle, + connecting, + refreshing, + rescanning, + processingEvent, + exiting, +} + +final class XelisOperationCoordinator { + XelisOperationCoordinator(this._mutex); + + final Mutex _mutex; + + Future? _latestSyncFuture; + Future? _pendingExitFuture; + ({XelisOperation operation, Future future})? _latestLifecycle; + + XelisOperation _activeOperation = XelisOperation.idle; + + XelisOperation get activeOperation => _activeOperation; + + Future connect( + Future Function() operation, { + bool joinExisting = true, + }) { + final latestLifecycle = _latestLifecycle; + if (joinExisting && + latestLifecycle?.operation == XelisOperation.connecting) { + return latestLifecycle!.future; + } + + late final Future future; + future = _run(XelisOperation.connecting, operation).whenComplete(() { + _clearLatestLifecycle(future); + _clearLatestSync(future); + }); + + _latestLifecycle = (operation: XelisOperation.connecting, future: future); + _latestSyncFuture = future; + return future; + } + + Future refresh(Future Function() operation) { + final latestSyncFuture = _latestSyncFuture; + if (latestSyncFuture != null) { + return latestSyncFuture; + } + + if (_pendingExitFuture != null) { + return Future.value(); + } + + return _scheduleSync(XelisOperation.refreshing, operation); + } + + Future rescan(Future Function() operation) { + if (_pendingExitFuture != null) { + return Future.error( + StateError('Cannot rescan a Xelis wallet while it is exiting'), + ); + } + + return _scheduleSync(XelisOperation.rescanning, operation); + } + + Future processEvent(Future Function() operation) { + if (_pendingExitFuture != null) { + return Future.value(); + } + + return _run(XelisOperation.processingEvent, operation); + } + + Future processSyncEvent(Future Function() operation) { + if (_pendingExitFuture != null) { + return Future.value(); + } + + return refresh(operation); + } + + Future exit(Future Function() operation) { + final latestLifecycle = _latestLifecycle; + if (latestLifecycle?.operation == XelisOperation.exiting) { + return latestLifecycle!.future; + } + + _latestSyncFuture = null; + + late final Future future; + future = _run(XelisOperation.exiting, operation).whenComplete(() { + if (identical(_pendingExitFuture, future)) { + _pendingExitFuture = null; + } + _clearLatestLifecycle(future); + }); + + _latestLifecycle = (operation: XelisOperation.exiting, future: future); + _pendingExitFuture = future; + return future; + } + + Future _scheduleSync( + XelisOperation operationType, + Future Function() operation, + ) { + late final Future future; + future = _run(operationType, operation).whenComplete(() { + _clearLatestSync(future); + }); + + _latestSyncFuture = future; + return future; + } + + Future _run( + XelisOperation operationType, + Future Function() operation, + ) { + return _mutex.protect(() async { + assert(_activeOperation == XelisOperation.idle); + _activeOperation = operationType; + try { + await operation(); + } finally { + _activeOperation = XelisOperation.idle; + } + }); + } + + void _clearLatestSync(Future completedFuture) { + if (identical(_latestSyncFuture, completedFuture)) { + _latestSyncFuture = null; + } + } + + void _clearLatestLifecycle(Future completedFuture) { + if (identical(_latestLifecycle?.future, completedFuture)) { + _latestLifecycle = null; + } + } +} diff --git a/lib/wallets/wallet/supporting/restore_progress.dart b/lib/wallets/wallet/supporting/restore_progress.dart new file mode 100644 index 0000000000..c2516b6b43 --- /dev/null +++ b/lib/wallets/wallet/supporting/restore_progress.dart @@ -0,0 +1,4 @@ +double calculateRestoreProgress({ + required int scannedHeight, + required int chainHeight, +}) => chainHeight <= 0 ? 0.0 : scannedHeight / chainHeight; diff --git a/lib/wallets/wallet/wallet.dart b/lib/wallets/wallet/wallet.dart index 1aa40ef6a7..86a56fd478 100644 --- a/lib/wallets/wallet/wallet.dart +++ b/lib/wallets/wallet/wallet.dart @@ -556,21 +556,7 @@ abstract class Wallet { ), ); } - if (shouldAutoSync) { - _periodicRefreshTimer ??= Timer.periodic(const Duration(seconds: 150), ( - timer, - ) async { - // chain height check currently broken - // if ((await chainHeight) != (await storedChainHeight)) { - - // TODO: [prio=med] some kind of quick check if wallet needs to refresh to replace the old refreshIfThereIsNewData call - // if (await refreshIfThereIsNewData()) { - unawaited(refresh()); - - // } - // } - }); - } + ensurePeriodicRefreshTimer(); }, onError: (Object e, StackTrace s) { if (!doNotFireRefreshEvents) { @@ -602,6 +588,16 @@ abstract class Wallet { return future; } + @protected + void ensurePeriodicRefreshTimer() { + if (shouldAutoSync) { + _periodicRefreshTimer ??= Timer.periodic( + const Duration(seconds: 150), + (_) => unawaited(refresh()), + ); + } + } + void _fireRefreshPercentChange(double percent) { if (this is ElectrumXInterface) { (this as ElectrumXInterface?)?.refreshingPercent = percent; @@ -753,7 +749,8 @@ abstract class Wallet { Future exit() async { Logging.instance.i("exit called on $walletId"); _periodicRefreshTimer?.cancel(); - _networkAliveTimer?.cancel(); + _periodicRefreshTimer = null; + _stopNetworkAlivePinging(); // If the syncing pref is currentWalletOnly or selectedWalletsAtStartup (and // this wallet isn't in walletIdsSyncOnStartup), then we close subscriptions. diff --git a/lib/wallets/wallet/wallet_mixin_interfaces/electrum_fee_planner.dart b/lib/wallets/wallet/wallet_mixin_interfaces/electrum_fee_planner.dart new file mode 100644 index 0000000000..55f38dac26 --- /dev/null +++ b/lib/wallets/wallet/wallet_mixin_interfaces/electrum_fee_planner.dart @@ -0,0 +1,100 @@ +enum ElectrumFeeMode { fixedAmount, subtractFeeFromAmount, sweep } + +final class ElectrumFeeInsufficientFunds implements Exception { + /// The fee the rejected transaction needed to pay. Callers retrying with + /// more funds should select inputs covering at least the recipient amount + /// plus this fee. + final BigInt requiredFee; + + const ElectrumFeeInsufficientFunds({required this.requiredFee}); +} + +typedef ElectrumFeeTransactionBuilder = + Future<({T transaction, int vSize})> Function({ + required BigInt recipientAmount, + BigInt? changeAmount, + }); + +final class ElectrumFeeResult { + final T transaction; + final BigInt fee; + + const ElectrumFeeResult({required this.transaction, required this.fee}); +} + +/// The fee paid is always at least [minimumFeeAmount] (when non-null), the +/// rate-based fee for the measured vSize, and one sat per vByte, whichever is +/// greatest. [minimumFeeAmount] is a floor, not an exact override. +Future> planElectrumFee({ + required ElectrumFeeMode mode, + required BigInt inputTotal, + required BigInt recipientAmount, + required BigInt dustLimit, + required int? satsPerVByte, + required BigInt feeRatePerKB, + required BigInt? minimumFeeAmount, + required ElectrumFeeTransactionBuilder build, +}) async { + if (mode != ElectrumFeeMode.sweep && recipientAmount < dustLimit) { + throw Exception( + "Recipient amount ($recipientAmount) is below dust limit ($dustLimit)", + ); + } + + BigInt requiredFeeFor(int vSize) { + final BigInt rateFee; + if (satsPerVByte != null) { + rateFee = BigInt.from(satsPerVByte * vSize); + } else { + final kb = BigInt.from(1000); + rateFee = (feeRatePerKB * BigInt.from(vSize) + kb - BigInt.one) ~/ kb; + } + + final vSizeFloor = BigInt.from(vSize); + final minimumFloor = minimumFeeAmount ?? BigInt.zero; + final feeFloor = rateFee > minimumFloor ? rateFee : minimumFloor; + return feeFloor > vSizeFloor ? feeFloor : vSizeFloor; + } + + final selectedSurplus = inputTotal - recipientAmount; + final subDustSurplus = + mode == .subtractFeeFromAmount && + selectedSurplus > BigInt.zero && + selectedSurplus < dustLimit + ? selectedSurplus + : BigInt.zero; + BigInt fee = -subDustSurplus; + while (true) { + final amountToSend = switch (mode) { + .fixedAmount => recipientAmount, + .subtractFeeFromAmount => recipientAmount - fee, + .sweep => inputTotal - fee, + }; + if (amountToSend < dustLimit) { + throw Exception("Estimated fee ($fee sats) leaves no spendable amount!"); + } + + final possibleChange = switch (mode) { + .fixedAmount => inputTotal - recipientAmount - fee, + .subtractFeeFromAmount => inputTotal - recipientAmount, + .sweep => null, + }; + final changeAmount = possibleChange != null && possibleChange >= dustLimit + ? possibleChange + : null; + + final built = await build( + recipientAmount: amountToSend, + changeAmount: changeAmount, + ); + final feePaid = inputTotal - amountToSend - (changeAmount ?? BigInt.zero); + final requiredFee = requiredFeeFor(built.vSize); + if (feePaid >= requiredFee) { + return ElectrumFeeResult(transaction: built.transaction, fee: feePaid); + } + if (mode == ElectrumFeeMode.fixedAmount && possibleChange! < dustLimit) { + throw ElectrumFeeInsufficientFunds(requiredFee: requiredFee); + } + fee += requiredFee - feePaid; + } +} diff --git a/lib/wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dart b/lib/wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dart index 100aa9f9fe..eaf21d927e 100644 --- a/lib/wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dart +++ b/lib/wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dart @@ -34,12 +34,41 @@ import '../impl/firo_wallet.dart'; import '../impl/peercoin_wallet.dart'; import '../intermediate/bip39_hd_wallet.dart'; import 'cpfp_interface.dart'; +import 'electrum_fee_planner.dart'; import 'mweb_interface.dart'; import 'paynym_interface.dart'; import 'rbf_interface.dart'; import 'sign_verify_interface.dart'; import 'view_only_option_interface.dart'; +@visibleForTesting +bool isMwebPegoutOutput(List outputs, int vout) { + if (vout <= 0) { + return false; + } + + for (final output in outputs) { + if (output is! Map || output["n"] != 0) { + continue; + } + + final scriptPubKey = output["scriptPubKey"]; + if (scriptPubKey is! Map) { + return false; + } + + if (scriptPubKey["type"] == "witness_mweb_hogaddr") { + return true; + } + + final scriptHex = scriptPubKey["hex"]; + return scriptHex is String && + RegExp(r'^5820[0-9a-fA-F]{64}$').hasMatch(scriptHex); + } + + return false; +} + mixin ElectrumXInterface on Bip39HDWallet implements ViewOnlyOptionInterface, SignVerifyInterface { @@ -123,14 +152,18 @@ mixin ElectrumXInterface required bool coinControl, required bool isSendAll, required bool isSendAllCoinControlUtxos, - int additionalOutputs = 0, List? utxos, - BigInt? overrideFeeAmount, + BigInt? minimumFeeAmount, }) async { Logging.instance.d("Starting coinSelection ----------"); // TODO: multiple recipients one day assert(txData.recipients!.length == 1); + if (txData.recipients!.length != 1) { + throw Exception( + "Transactions with more than one recipient are not supported", + ); + } if (coinControl && utxos == null) { throw Exception("Coin control used where utxos is null!"); @@ -199,7 +232,8 @@ mixin ElectrumXInterface throw Exception("Insufficient balance"); } else if (spendableSatoshiValue == satoshiAmountToSend && !isSendAll && - !isSendAllCoinControlUtxos) { + !isSendAllCoinControlUtxos && + !txData.subtractFeeFromAmount) { throw Exception("Insufficient balance to pay transaction fee"); } @@ -224,411 +258,193 @@ mixin ElectrumXInterface Logging.instance.d("satoshiAmountToSend: $satoshiAmountToSend"); // Use coinlib CoinSelection algorithms except for - // "coinControl", "SendAll", "MWEB", "overrideFeeAmount", + // "coinControl", "SendAll", "MWEB", "minimumFeeAmount", + // and "subtractFeeFromAmount" // because they do not need a selection or // do not meet the requirements for the algorithms final bool useOptimalSelection = !coinControl && !isSendAll && !isSendAllCoinControlUtxos && - overrideFeeAmount == null && + !txData.subtractFeeFromAmount && + minimumFeeAmount == null && txData.type != TxType.mweb && txData.type != TxType.mwebPegOut && txData.type != TxType.mwebPegIn; if (useOptimalSelection) { - return await _optimalCoinSelection( - txData: txData, - spendableOutputs: spendableOutputs.whereType().toList(), - recipientAddress: recipientAddress, - satoshiAmountToSend: satoshiAmountToSend, - satsPerVByte: satsPerVByte, - feeRatePerKB: selectedTxFeeRate, - changeAddress: await changeAddress(), - ); + try { + return await _optimalCoinSelection( + txData: txData, + spendableOutputs: spendableOutputs + .whereType() + .toList(), + recipientAddress: recipientAddress, + satoshiAmountToSend: satoshiAmountToSend, + satsPerVByte: satsPerVByte, + feeRatePerKB: selectedTxFeeRate, + changeAddress: await changeAddress(), + ); + } on ElectrumFeeInsufficientFunds catch (e) { + Logging.instance.w( + "Optimal coin selection could not cover the measured transaction " + "fee (${e.requiredFee} sats). Falling back to previous/old input " + "selection.", + ); + } } BigInt satoshisBeingUsed = BigInt.zero; int inputsBeingConsumed = 0; final List utxoObjectsToUse = []; + final List inputsWithKeys = []; - if (!coinControl) { - for ( - var i = 0; - satoshisBeingUsed < satoshiAmountToSend && i < spendableOutputs.length; - i++ - ) { - utxoObjectsToUse.add(spendableOutputs[i]); - satoshisBeingUsed += spendableOutputs[i].value; - inputsBeingConsumed += 1; - } - for ( - int i = 0; - i < additionalOutputs && inputsBeingConsumed < spendableOutputs.length; - i++ - ) { - utxoObjectsToUse.add(spendableOutputs[inputsBeingConsumed]); - satoshisBeingUsed += spendableOutputs[inputsBeingConsumed].value; - inputsBeingConsumed += 1; + /// Consume spendable outputs until [target] is covered (all of them when + /// using coin control), gathering signing data for newly added inputs. + Future consumeInputsFor(BigInt target) async { + final start = inputsBeingConsumed; + if (coinControl) { + satoshisBeingUsed = spendableSatoshiValue; + utxoObjectsToUse.addAll(spendableOutputs); + inputsBeingConsumed = spendableOutputs.length; + } else { + while (satoshisBeingUsed < target && + inputsBeingConsumed < spendableOutputs.length) { + utxoObjectsToUse.add(spendableOutputs[inputsBeingConsumed]); + satoshisBeingUsed += spendableOutputs[inputsBeingConsumed].value; + inputsBeingConsumed += 1; + } } - } else { - satoshisBeingUsed = spendableSatoshiValue; - utxoObjectsToUse.addAll(spendableOutputs); - inputsBeingConsumed = spendableOutputs.length; - } - - Logging.instance.d("satoshisBeingUsed: $satoshisBeingUsed"); - Logging.instance.d("inputsBeingConsumed: $inputsBeingConsumed"); - Logging.instance.d('utxoObjectsToUse: $utxoObjectsToUse'); + inputsWithKeys.addAll( + await addSigningKeys(utxoObjectsToUse.sublist(start)), + ); - // numberOfOutputs' length must always be equal to that of recipientsArray and recipientsAmtArray - final List recipientsArray = [recipientAddress]; - final List recipientsAmtArray = [satoshiAmountToSend]; + Logging.instance.d("satoshisBeingUsed: $satoshisBeingUsed"); + Logging.instance.d("inputsBeingConsumed: $inputsBeingConsumed"); + Logging.instance.d('utxoObjectsToUse: $utxoObjectsToUse'); + } - // gather required signing data - final inputsWithKeys = await addSigningKeys(utxoObjectsToUse); + await consumeInputsFor(satoshiAmountToSend); if (isSendAll || isSendAllCoinControlUtxos) { - if ((overrideFeeAmount ?? BigInt.zero) + satoshiAmountToSend != + if ((minimumFeeAmount ?? BigInt.zero) + satoshiAmountToSend != satoshisBeingUsed) { Logging.instance.d("txData.type: ${txData.type}"); Logging.instance.d("isSendAll: $isSendAll"); Logging.instance.d( "isSendAllCoinControlUtxos: $isSendAllCoinControlUtxos", ); - Logging.instance.d("overrideFeeAmount: $overrideFeeAmount"); + Logging.instance.d("minimumFeeAmount: $minimumFeeAmount"); Logging.instance.d("satoshiAmountToSend: $satoshiAmountToSend"); Logging.instance.d("satoshisBeingUsed: $satoshisBeingUsed"); // hack check if (!(txData.type == TxType.mwebPegIn || - (txData.type.isMweb() && overrideFeeAmount != null))) { + (txData.type.isMweb() && minimumFeeAmount != null))) { throw Exception( "Something happened that should never actually happen. " "Please report this error to the developers.", ); } } - return await _sendAllBuilder( + return await _buildTransactionPayingFee( txData: txData, - recipientAddress: recipientAddress, - satoshisBeingUsed: satoshisBeingUsed, inputsWithKeys: inputsWithKeys, + recipientAddress: recipientAddress, + recipientAmount: satoshiAmountToSend, + inputTotal: satoshisBeingUsed, satsPerVByte: satsPerVByte, feeRatePerKB: selectedTxFeeRate, - overrideFeeAmount: overrideFeeAmount, + minimumFeeAmount: minimumFeeAmount, + isSweep: true, + nextChangeAddress: () async => (await changeAddress()).value, ); } - final int vSizeForOneOutput; - try { - vSizeForOneOutput = (await buildTransaction( - inputsWithKeys: inputsWithKeys, - txData: txData.copyWith( - recipients: await helperRecipientsConvert( - [recipientAddress], - [satoshisBeingUsed - BigInt.one], - ), - ), - )).vSize!; - } catch (e, s) { - Logging.instance.e("vSizeForOneOutput: $e", error: e, stackTrace: s); - rethrow; - } - - final int vSizeForTwoOutPuts; - - BigInt maxBI(BigInt a, BigInt b) => a > b ? a : b; - - try { - vSizeForTwoOutPuts = (await buildTransaction( - inputsWithKeys: inputsWithKeys, - txData: txData.copyWith( - recipients: await helperRecipientsConvert( - [recipientAddress, (await changeAddress()).value], - [ - satoshiAmountToSend, - maxBI( - BigInt.zero, - satoshisBeingUsed - (satoshiAmountToSend + BigInt.one), - ), - ], - ), - ), - )).vSize!; - } catch (e, s) { - Logging.instance.e("vSizeForTwoOutPuts: $e", error: e, stackTrace: s); - rethrow; - } - - // Assume 1 output, only for recipient and no change - final feeForOneOutput = - overrideFeeAmount ?? - BigInt.from( - satsPerVByte != null - ? (satsPerVByte * vSizeForOneOutput) - : estimateTxFee( - vSize: vSizeForOneOutput, - feeRatePerKB: selectedTxFeeRate, - ), - ); - // Assume 2 outputs, one for recipient and one for change - final feeForTwoOutputs = - overrideFeeAmount ?? - BigInt.from( - satsPerVByte != null - ? (satsPerVByte * vSizeForTwoOutPuts) - : estimateTxFee( - vSize: vSizeForTwoOutPuts, - feeRatePerKB: selectedTxFeeRate, - ), - ); - - Logging.instance.d("feeForTwoOutputs: $feeForTwoOutputs"); - Logging.instance.d("feeForOneOutput: $feeForOneOutput"); - - final difference = satoshisBeingUsed - satoshiAmountToSend; - - Future singleOutputTxn() async { - Logging.instance.d('Input size: $satoshisBeingUsed'); - Logging.instance.d('Recipient output size: $satoshiAmountToSend'); - Logging.instance.d('Fee being paid: $difference sats'); - Logging.instance.d('Estimated fee: $feeForOneOutput'); - final txnData = await buildTransaction( - inputsWithKeys: inputsWithKeys, - txData: txData.copyWith( - recipients: await helperRecipientsConvert( - recipientsArray, - recipientsAmtArray, - ), - ), - ); - return txnData.copyWith( - fee: Amount( - rawValue: feeForOneOutput, - fractionDigits: cryptoCurrency.fractionDigits, - ), - usedUTXOs: inputsWithKeys, - ); - } - - // no change output required - if (difference == feeForOneOutput) { - Logging.instance.d('1 output in tx'); - return await singleOutputTxn(); - } else if (difference < feeForOneOutput) { - Logging.instance.w( - 'Cannot pay tx fee - checking for more outputs and trying again', - ); - // try adding more outputs - if (spendableOutputs.length > inputsBeingConsumed) { - return coinSelection( + while (true) { + try { + return await _buildTransactionPayingFee( txData: txData, - isSendAll: isSendAll, - additionalOutputs: additionalOutputs + 1, - utxos: utxos, - coinControl: coinControl, - isSendAllCoinControlUtxos: isSendAllCoinControlUtxos, - overrideFeeAmount: overrideFeeAmount, + inputsWithKeys: inputsWithKeys, + recipientAddress: recipientAddress, + recipientAmount: satoshiAmountToSend, + inputTotal: satoshisBeingUsed, + satsPerVByte: satsPerVByte, + feeRatePerKB: selectedTxFeeRate, + minimumFeeAmount: minimumFeeAmount, + isSweep: false, + nextChangeAddress: () async { + if (!(txData.type == TxType.mweb || + txData.type == TxType.mwebPegOut)) { + await checkChangeAddressForTransactions(); + } + return (await changeAddress()).value; + }, ); - } - throw Exception("Insufficient balance to pay transaction fee"); - } else { - if (difference > (feeForOneOutput + cryptoCurrency.dustLimit.raw)) { - final changeOutputSize = difference - feeForTwoOutputs; - // check if possible to add the change output - if (changeOutputSize > cryptoCurrency.dustLimit.raw && - difference - changeOutputSize == feeForTwoOutputs) { - if (!(txData.type == TxType.mweb || - txData.type == TxType.mwebPegOut)) { - // generate new change address if current change address has been used - await checkChangeAddressForTransactions(); - } - final newChangeAddress = await changeAddress(); - - BigInt feeBeingPaid = difference - changeOutputSize; - - // add change output - recipientsArray.add(newChangeAddress.value); - recipientsAmtArray.add(changeOutputSize); - - Logging.instance.d('2 outputs in tx'); - Logging.instance.d('Input size: $satoshisBeingUsed'); - Logging.instance.d('Recipient output size: $satoshiAmountToSend'); - Logging.instance.d('Change Output Size: $changeOutputSize'); - Logging.instance.d('Difference (fee being paid): $feeBeingPaid sats'); - Logging.instance.d('Estimated fee: $feeForTwoOutputs'); - - TxData txnData = await buildTransaction( - inputsWithKeys: inputsWithKeys, - txData: txData.copyWith( - recipients: await helperRecipientsConvert( - recipientsArray, - recipientsAmtArray, - ), - usedUTXOs: inputsWithKeys, - ), - ); - - // make sure minimum fee is accurate if that is being used - if (BigInt.from(txnData.vSize!) - feeBeingPaid == BigInt.one) { - final changeOutputSize = difference - BigInt.from(txnData.vSize!); - feeBeingPaid = difference - changeOutputSize; - recipientsAmtArray.removeLast(); - recipientsAmtArray.add(changeOutputSize); - - Logging.instance.d('Adjusted Input size: $satoshisBeingUsed'); - Logging.instance.d( - 'Adjusted Recipient output size: $satoshiAmountToSend', - ); - Logging.instance.d( - 'Adjusted Change Output Size: $changeOutputSize', - ); - Logging.instance.d( - 'Adjusted Difference (fee being paid): $feeBeingPaid sats', - ); - Logging.instance.d('Adjusted Estimated fee: $feeForTwoOutputs'); - - txnData = await buildTransaction( - inputsWithKeys: inputsWithKeys, - txData: txData.copyWith( - recipients: await helperRecipientsConvert( - recipientsArray, - recipientsAmtArray, - ), - usedUTXOs: inputsWithKeys, - ), - ); - } - - return txnData.copyWith( - fee: Amount( - rawValue: feeBeingPaid, - fractionDigits: cryptoCurrency.fractionDigits, - ), - usedUTXOs: inputsWithKeys, - ); - } else { - // Something went wrong here. It either overshot or undershot the estimated fee amount or the changeOutputSize - // is smaller than or equal to cryptoCurrency.dustLimit. Revert to single output transaction. - Logging.instance.d('Reverting to 1 output in tx'); - - return await singleOutputTxn(); + } on ElectrumFeeInsufficientFunds catch (e) { + if (coinControl || inputsBeingConsumed >= spendableOutputs.length) { + throw Exception("Insufficient balance to pay transaction fee"); } + Logging.instance.w( + "Cannot pay tx fee (${e.requiredFee} sats) -" + " selecting more inputs and trying again", + ); + // Select enough to also cover the fee the last attempt needed. The + // added inputs grow the transaction, so the next attempt may still + // fall short and raise the target again until it converges. + await consumeInputsFor(satoshiAmountToSend + e.requiredFee); } } - - return txData; } - Future _sendAllBuilder({ + Future _buildTransactionPayingFee({ required TxData txData, - required String recipientAddress, - required BigInt satoshisBeingUsed, required List inputsWithKeys, + required String recipientAddress, + required BigInt recipientAmount, + required BigInt inputTotal, required int? satsPerVByte, required BigInt feeRatePerKB, - BigInt? overrideFeeAmount, + required BigInt? minimumFeeAmount, + required bool isSweep, + required Future Function() nextChangeAddress, }) async { - Logging.instance.d("Attempting to send all $cryptoCurrency"); - if (txData.recipients!.length != 1) { - throw Exception("Send all to more than one recipient not yet supported"); - } - - BigInt feeForOneOutput; - if (overrideFeeAmount == null) { - final int vSizeForOneOutput = (await buildTransaction( - inputsWithKeys: inputsWithKeys, - txData: txData.copyWith( - recipients: await helperRecipientsConvert( - [recipientAddress], - [satoshisBeingUsed - BigInt.one], - ), - ), - )).vSize!; - feeForOneOutput = BigInt.from( - satsPerVByte != null - ? (satsPerVByte * vSizeForOneOutput) - : estimateTxFee( - vSize: vSizeForOneOutput, - feeRatePerKB: feeRatePerKB, - ), - ); - - if (satsPerVByte == null) { - final roughEstimate = roughFeeEstimate( - inputsWithKeys.length, - 1, - feeRatePerKB, - ).raw; - if (feeForOneOutput < roughEstimate) { - feeForOneOutput = roughEstimate; - } - } - } else { - feeForOneOutput = overrideFeeAmount; - } - - late TxData data; - if (txData.type == TxType.mwebPegIn) { - while (true) { - final satoshiAmountToSend = satoshisBeingUsed - feeForOneOutput; - if (satoshiAmountToSend.isNegative) { - throw Exception( - "Estimated fee ($feeForOneOutput sats) is greater than balance!", - ); + final BigInt dustLimit = cryptoCurrency.dustLimit.raw; + String? changeAddress; + final result = await planElectrumFee( + mode: isSweep + ? ElectrumFeeMode.sweep + : txData.subtractFeeFromAmount + ? ElectrumFeeMode.subtractFeeFromAmount + : ElectrumFeeMode.fixedAmount, + inputTotal: inputTotal, + recipientAmount: recipientAmount, + dustLimit: dustLimit, + satsPerVByte: satsPerVByte, + feeRatePerKB: feeRatePerKB, + minimumFeeAmount: minimumFeeAmount, + build: ({required recipientAmount, changeAmount}) async { + final addresses = [recipientAddress]; + final amounts = [recipientAmount]; + if (changeAmount != null) { + final address = changeAddress ??= await nextChangeAddress(); + addresses.add(address); + amounts.add(changeAmount); } - - data = await buildTransaction( + final transaction = await buildTransaction( + inputsWithKeys: inputsWithKeys, txData: txData.copyWith( - recipients: await helperRecipientsConvert( - [recipientAddress], - [satoshiAmountToSend], - ), + recipients: await helperRecipientsConvert(addresses, amounts), + usedUTXOs: inputsWithKeys, ), - inputsWithKeys: inputsWithKeys, - ); - - if (overrideFeeAmount != null) { - break; - } - - // Signing can change vSize, so calculate the fee from the final tx. - final vSize = BigInt.from(data.vSize!); - final feeForFinalVSize = BigInt.from( - satsPerVByte != null - ? satsPerVByte * data.vSize! - : estimateTxFee(vSize: data.vSize!, feeRatePerKB: feeRatePerKB), - ); - final requiredFee = feeForFinalVSize > vSize ? feeForFinalVSize : vSize; - if (feeForOneOutput >= requiredFee) { - break; - } - feeForOneOutput = requiredFee; - } - } else { - final satoshiAmountToSend = satoshisBeingUsed - feeForOneOutput; - - if (satoshiAmountToSend.isNegative) { - throw Exception( - "Estimated fee ($feeForOneOutput sats) is greater than balance!", ); - } - - data = await buildTransaction( - txData: txData.copyWith( - recipients: await helperRecipientsConvert( - [recipientAddress], - [satoshiAmountToSend], - ), - ), - inputsWithKeys: inputsWithKeys, - ); - } + return (transaction: transaction, vSize: transaction.vSize!); + }, + ); - return data.copyWith( + return result.transaction.copyWith( fee: Amount( - rawValue: feeForOneOutput, + rawValue: result.fee, fractionDigits: cryptoCurrency.fractionDigits, ), usedUTXOs: inputsWithKeys, @@ -794,33 +610,25 @@ mixin ElectrumXInterface " signedSize=${selection.signedSize}", ); - /// Add the change if there is one - final List recipientsArray = [recipientAddress]; - final List recipientsAmtArray = [satoshiAmountToSend]; - if (!selection.changeless) { - await checkChangeAddressForTransactions(); - final freshChange = (await getCurrentChangeAddress())!; - recipientsArray.add(freshChange.value); - recipientsAmtArray.add(selection.changeValue); - } - - final TxData txBuilt = await buildTransaction( - inputsWithKeys: selectedBaseInputs, - txData: txData.copyWith( - recipients: await helperRecipientsConvert( - recipientsArray, - recipientsAmtArray, - ), - usedUTXOs: selectedBaseInputs, - ), + final BigInt inputTotal = selectedBaseInputs.fold( + BigInt.zero, + (sum, input) => sum + input.value, ); - return txBuilt.copyWith( - fee: Amount( - rawValue: selection.fee, - fractionDigits: cryptoCurrency.fractionDigits, - ), - usedUTXOs: selectedBaseInputs, + return _buildTransactionPayingFee( + txData: txData, + inputsWithKeys: selectedBaseInputs, + recipientAddress: recipientAddress, + recipientAmount: satoshiAmountToSend, + inputTotal: inputTotal, + satsPerVByte: satsPerVByte, + feeRatePerKB: feeRatePerKB, + minimumFeeAmount: null, + isSweep: false, + nextChangeAddress: () async { + await checkChangeAddressForTransactions(); + return (await getCurrentChangeAddress())!.value; + }, ); } @@ -1583,6 +1391,9 @@ mixin ElectrumXInterface final vout = jsonUTXO["tx_pos"] as int; final outputs = txn["vout"] as List; + final mwebPegoutMaturity = cryptoCurrency.mwebPegoutMaturity; + final isMwebPegout = + mwebPegoutMaturity != null && isMwebPegoutOutput(outputs, vout); String? scriptPubKey; String? utxoOwnerAddress; @@ -1620,6 +1431,11 @@ mixin ElectrumXInterface blockHeight: jsonUTXO["height"] as int?, blockTime: txn["blocktime"] as int?, address: utxoOwnerAddress, + otherData: isMwebPegout + ? jsonEncode({ + UTXOOtherDataKeys.mwebPegoutMaturity: mwebPegoutMaturity, + }) + : null, ); return utxo; @@ -2193,13 +2009,15 @@ mixin ElectrumXInterface TxData mwebData = await coinSelection( txData: result.copyWith( - recipients: result.recipients!.where((e) => !(e.isChange)).toList(), + recipients: txData.subtractFeeFromAmount + ? txData.recipients + : result.recipients!.where((e) => !(e.isChange)).toList(), ), utxos: utxos?.toList(), coinControl: coinControl, isSendAll: isSendAll, isSendAllCoinControlUtxos: isSendAllCoinControlUtxos, - overrideFeeAmount: fee.raw, + minimumFeeAmount: fee.raw, ); if (mwebData.type == TxType.mwebPegIn) { @@ -2212,7 +2030,7 @@ mixin ElectrumXInterface mwebData, ); Logging.instance.d("prepare MWEB send: $data"); - return data.copyWith(fee: fee); + return data; } Logging.instance.d("prepare send: $result"); diff --git a/lib/wallets/wallet/wallet_mixin_interfaces/mweb_interface.dart b/lib/wallets/wallet/wallet_mixin_interfaces/mweb_interface.dart index bfeb24e72f..5f9fe71589 100644 --- a/lib/wallets/wallet/wallet_mixin_interfaces/mweb_interface.dart +++ b/lib/wallets/wallet/wallet_mixin_interfaces/mweb_interface.dart @@ -442,12 +442,17 @@ mixin MwebInterface Future processMwebTransaction(TxData txData) async { final client = await _client; + final vBytesPerKilobyte = BigInt.from(1000); + final customSatsPerVByte = txData.satsPerVByte; + final feeRatePerKB = customSatsPerVByte != null + ? BigInt.from(customSatsPerVByte) * vBytesPerKilobyte + : txData.feeRateAmount!; final response = await client.create( CreateRequest( rawTx: txData.raw!.toUint8ListFromHex, scanSecret: await _scanSecret, spendSecret: await _spendSecret, - feeRatePerKb: Int64(txData.feeRateAmount!.toInt()), + feeRatePerKb: Int64(feeRatePerKB.toInt()), dryRun: false, ), ); @@ -968,8 +973,11 @@ mixin MwebInterface final preOutputSum = outputs.fold(BigInt.zero, (p, e) => p + e.amount.raw); final fee = sumOfUtxosValue - preOutputSum; - final feeRate = - txData.satsPerVByte ?? (txData.feeRateAmount!.toInt() / 1000).ceil(); + final vBytesPerKilobyte = BigInt.from(1000); + final customSatsPerVByte = txData.satsPerVByte; + final feeRatePerKB = customSatsPerVByte != null + ? BigInt.from(customSatsPerVByte) * vBytesPerKilobyte + : txData.feeRateAmount!; final client = await _client; @@ -978,7 +986,7 @@ mixin MwebInterface rawTx: txData.raw!.toUint8ListFromHex, scanSecret: await _scanSecret, spendSecret: await _spendSecret, - feeRatePerKb: Int64(feeRate * 1000), + feeRatePerKb: Int64(feeRatePerKB.toInt()), dryRun: true, ), ); @@ -1010,7 +1018,9 @@ mixin MwebInterface BigInt feeIncrease = posOutputSum - expectedPegin; if (expectedPegin > BigInt.zero) { - feeIncrease += BigInt.from(feeRate * 41); + feeIncrease += + (feeRatePerKB * BigInt.from(41) + vBytesPerKilobyte - BigInt.one) ~/ + vBytesPerKilobyte; } return Amount( diff --git a/lib/wallets/wallet/wallet_mixin_interfaces/nano_interface.dart b/lib/wallets/wallet/wallet_mixin_interfaces/nano_interface.dart index b08b6bb426..1b12d1ed3b 100644 --- a/lib/wallets/wallet/wallet_mixin_interfaces/nano_interface.dart +++ b/lib/wallets/wallet/wallet_mixin_interfaces/nano_interface.dart @@ -37,6 +37,25 @@ Map _buildHeaders(String url) { return result; } +({String frontier, String representative, BigInt balanceAfterSend}) +parseNanoSendState(Map accountInfo, BigInt sendAmount) { + if (accountInfo["error"] != null) { + throw Exception("account_info error: ${accountInfo["error"]}"); + } + final liveBalance = BigInt.tryParse(accountInfo["balance"].toString()); + if (liveBalance == null) { + throw Exception("Invalid account_info balance"); + } + if (sendAmount > liveBalance) { + throw Exception("Insufficient balance"); + } + return ( + frontier: accountInfo["frontier"].toString(), + representative: accountInfo["representative"].toString(), + balanceAfterSend: liveBalance - sendAmount, + ); +} + mixin NanoInterface on Bip39Wallet { // since nano based coins only have a single address/account we can cache // the address instead of fetching from db every time we need it in certain @@ -412,12 +431,6 @@ mixin NanoInterface on Bip39Wallet { final String publicAddress = (_cachedAddress ?? await getCurrentReceivingAddress())!.value; - // first update to get latest account balance: - - final currentBalance = info.cachedBalance.spendable; - final txAmount = txData.amount!; - final BigInt balanceAfterTx = (currentBalance - txAmount).raw; - // get the account info (we need the frontier and representative): final infoBody = jsonEncode({ "action": "account_info", @@ -435,12 +448,10 @@ mixin NanoInterface on Bip39Wallet { : null, ); - final String frontier = jsonDecode( - infoResponse.body, - )["frontier"].toString(); - final String representative = jsonDecode( - infoResponse.body, - )["representative"].toString(); + final accountInfo = Map.from( + jsonDecode(infoResponse.body) as Map, + ); + final sendState = parseNanoSendState(accountInfo, txData.amount!.raw); // link = destination address: final String linkAsAccount = txData.recipients!.first.address; final String link = NanoAccounts.extractPublicKey(linkAsAccount); @@ -449,9 +460,9 @@ mixin NanoInterface on Bip39Wallet { final Map sendBlock = { "type": "state", "account": publicAddress, - "previous": frontier, - "representative": representative, - "balance": balanceAfterTx.toString(), + "previous": sendState.frontier, + "representative": sendState.representative, + "balance": sendState.balanceAfterSend.toString(), "link": link, }; @@ -468,7 +479,7 @@ mixin NanoInterface on Bip39Wallet { final String signature = NanoSignatures.signBlock(hash, privateKey); // get PoW for the send block: - final String? work = await _requestWork(frontier); + final String? work = await _requestWork(sendState.frontier); if (work == null) { throw Exception("Failed to get PoW for send block"); } diff --git a/lib/wallets/wallet/wallet_mixin_interfaces/paynym_interface.dart b/lib/wallets/wallet/wallet_mixin_interfaces/paynym_interface.dart index 6c815ec12d..a38df01671 100644 --- a/lib/wallets/wallet/wallet_mixin_interfaces/paynym_interface.dart +++ b/lib/wallets/wallet/wallet_mixin_interfaces/paynym_interface.dart @@ -9,6 +9,7 @@ import 'package:bitcoindart/src/utils/constants/op.dart' as op; import 'package:bitcoindart/src/utils/script.dart' as bscript; import 'package:coinlib_flutter/coinlib_flutter.dart' as coinlib; import 'package:isar_community/isar.dart'; +import 'package:meta/meta.dart'; import 'package:pointycastle/digests/sha256.dart'; import 'package:tuple/tuple.dart'; @@ -24,7 +25,6 @@ import '../../../utilities/bip32_utils.dart'; import '../../../utilities/bip47_utils.dart'; import '../../../utilities/enums/derive_path_type_enum.dart'; import '../../../utilities/extensions/extensions.dart'; -import '../../../utilities/format.dart'; import '../../../utilities/logger.dart'; import '../../crypto_currency/crypto_currency.dart'; import '../../crypto_currency/interfaces/paynym_currency_interface.dart'; @@ -46,6 +46,29 @@ String _receivingPaynymAddressDerivationPath( String _sendPaynymAddressDerivationPath(int index, {required bool testnet}) => "${_basePaynymDerivePath(testnet: testnet)}/0/$index"; +@visibleForTesting +int comparePaynymNotificationUtxos(UTXO a, UTXO b) { + final aIsTaproot = + a.address?.startsWith('bc1p') == true || + a.address?.startsWith('tb1p') == true; + final bIsTaproot = + b.address?.startsWith('bc1p') == true || + b.address?.startsWith('tb1p') == true; + if (aIsTaproot != bIsTaproot) { + return aIsTaproot ? 1 : -1; + } + return a.blockTime!.compareTo(b.blockTime!); +} + +@visibleForTesting +void validatePaynymNotificationInputs(List inputs) { + if (inputs.first.derivePathType == DerivePathType.bip86) { + throw PaynymSendException( + "A non-Taproot UTXO is required for a PayNym notification transaction.", + ); + } +} + mixin PaynymInterface on Bip39HDWallet, ElectrumXInterface { btc_dart.NetworkType get networkType => btc_dart.NetworkType( @@ -570,18 +593,7 @@ mixin PaynymInterface // Sort spendable by age (oldest first), but push taproot UTXOs to the // end since taproot inputs don't expose the raw public key needed by the // receiver to compute ECDH for BIP47 notification parsing. - spendableOutputs.sort((a, b) { - final aIsTaproot = - a.address?.startsWith('bc1p') == true || - a.address?.startsWith('tb1p') == true; - final bIsTaproot = - b.address?.startsWith('bc1p') == true || - b.address?.startsWith('tb1p') == true; - if (aIsTaproot != bIsTaproot) { - return aIsTaproot ? 1 : -1; - } - return b.blockTime!.compareTo(a.blockTime!); - }); + spendableOutputs.sort(comparePaynymNotificationUtxos); BigInt satoshisBeingUsed = BigInt.zero; int outputsBeingUsed = 0; @@ -615,6 +627,8 @@ mixin PaynymInterface utxoObjectsToUse.map((e) => StandardInput(e)).toList(), )).whereType().toList(); + validatePaynymNotificationInputs(inputsWithKeys); + final vSizeForNoChange = BigInt.from( (await _createNotificationTx( targetPaymentCodeString: targetPaymentCodeString, diff --git a/lib/widgets/dialogs/frost/frost_step_qr_dialog.dart b/lib/widgets/dialogs/frost/frost_step_qr_dialog.dart index 949e2fed75..81f3400200 100644 --- a/lib/widgets/dialogs/frost/frost_step_qr_dialog.dart +++ b/lib/widgets/dialogs/frost/frost_step_qr_dialog.dart @@ -7,7 +7,6 @@ import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:path_provider/path_provider.dart'; - import 'package:share_plus/share_plus.dart'; import '../../../notifications/show_flush_bar.dart'; @@ -94,9 +93,11 @@ class _FrostStepQrDialogState extends State { final file = await File("${tempDir.path}/qrcode.png").create(); await file.writeAsBytes(pngBytes); - await Share.shareFiles( - ["${tempDir.path}/qrcode.png"], - text: "Receive URI QR Code", + await SharePlus.instance.share( + ShareParams( + files: [XFile("${tempDir.path}/qrcode.png")], + text: "Receive URI QR Code", + ), ); } } catch (e) { @@ -124,21 +125,18 @@ class _FrostStepQrDialogState extends State { Text( widget.myName, style: STextStyles.w600_16(context).copyWith( - color: Theme.of(context) - .extension()! - .customTextButtonEnabledText, + color: Theme.of( + context, + ).extension()!.customTextButtonEnabledText, ), ), const SizedBox(height: 8), - Text( - widget.title, - style: STextStyles.w600_12(context), - ), + Text(widget.title, style: STextStyles.w600_12(context)), const SizedBox(height: 8), RoundedContainer( - color: Theme.of(context) - .extension()! - .textFieldDefaultBG, + color: Theme.of( + context, + ).extension()!.textFieldDefaultBG, radiusMultiplier: 1, child: Column( crossAxisAlignment: CrossAxisAlignment.center, @@ -146,9 +144,7 @@ class _FrostStepQrDialogState extends State { ConditionalParent( condition: Util.isDesktop, builder: (child) => ConstrainedBox( - constraints: const BoxConstraints( - maxWidth: 360, - ), + constraints: const BoxConstraints(maxWidth: 360), child: child, ), child: Padding( @@ -174,10 +170,7 @@ class _FrostStepQrDialogState extends State { ), ), ), - if (!Util.isDesktop) - const SizedBox( - height: 16, - ), + if (!Util.isDesktop) const SizedBox(height: 16), if (!Util.isDesktop) Row( children: [ @@ -190,9 +183,9 @@ class _FrostStepQrDialogState extends State { Assets.svg.share, width: 14, height: 14, - color: Theme.of(context) - .extension()! - .buttonTextSecondary, + color: Theme.of( + context, + ).extension()!.buttonTextSecondary, ), onPressed: () async { await _capturePng(false); diff --git a/lib/widgets/eth_fee_form.dart b/lib/widgets/eth_fee_form.dart index 2f4e2889ee..eba41b3f74 100644 --- a/lib/widgets/eth_fee_form.dart +++ b/lib/widgets/eth_fee_form.dart @@ -5,55 +5,67 @@ import 'package:flutter/material.dart'; import '../services/ethereum/ethereum_api.dart'; import '../themes/stack_colors.dart'; -import '../utilities/constants.dart'; +import '../utilities/amount/amount.dart'; +import '../utilities/amount/amount_field_relocalization.dart'; +import '../utilities/amount/amount_input_formatter.dart'; +import '../utilities/integer_input.dart'; import '../utilities/text_styles.dart'; import '../utilities/util.dart'; -import 'stack_text_field.dart'; +import 'textfields/adaptive_text_field.dart'; @immutable class EthEIP1559Fee { - final Decimal maxBaseFeeGwei; - final Decimal priorityFeeGwei; + final Decimal maxFeePerGasGwei; + final Decimal maxPriorityFeePerGasGwei; final int gasLimit; const EthEIP1559Fee({ - required this.maxBaseFeeGwei, - required this.priorityFeeGwei, + required this.maxFeePerGasGwei, + required this.maxPriorityFeePerGasGwei, required this.gasLimit, }); - BigInt get maxBaseFeeWei => maxBaseFeeGwei.shift(9).toBigInt(); - BigInt get priorityFeeWei => priorityFeeGwei.shift(9).toBigInt(); + BigInt get maxFeePerGasWei => maxFeePerGasGwei.shift(9).toBigInt(); + BigInt get maxPriorityFeePerGasWei => + maxPriorityFeePerGasGwei.shift(9).toBigInt(); + + bool get hasValidFeeCaps => + maxFeePerGasGwei > Decimal.zero && + maxPriorityFeePerGasGwei >= Decimal.zero && + maxFeePerGasGwei >= maxPriorityFeePerGasGwei; @override String toString() => "EthEIP1559Fee(" - "maxBaseFeeGwei: $maxBaseFeeGwei, " - "priorityFeeGwei: $priorityFeeGwei, " - "maxBaseFeeWei: $maxBaseFeeWei, " - "priorityFeeWei: $priorityFeeWei, " + "maxFeePerGasGwei: $maxFeePerGasGwei, " + "maxPriorityFeePerGasGwei: $maxPriorityFeePerGasGwei, " + "maxFeePerGasWei: $maxFeePerGasWei, " + "maxPriorityFeePerGasWei: $maxPriorityFeePerGasWei, " "gasLimit: $gasLimit)"; } class EthFeeForm extends StatefulWidget { EthFeeForm({ super.key, + required this.locale, this.minGasLimit = 21000, this.maxGasLimit = 30000000, this.initialState, required this.stateChanged, }) : assert( initialState == null || - (initialState.gasLimit >= minGasLimit && + (initialState.hasValidFeeCaps && + initialState.gasLimit >= minGasLimit && initialState.gasLimit <= maxGasLimit), ); final int minGasLimit; final int maxGasLimit; + final String locale; final EthEIP1559Fee? initialState; - final void Function(EthEIP1559Fee) stateChanged; + final void Function(EthEIP1559Fee?) stateChanged; @override State createState() => _EthFeeFormState(); @@ -62,34 +74,130 @@ class EthFeeForm extends StatefulWidget { class _EthFeeFormState extends State { static const _textFadeDuration = Duration(milliseconds: 300); - final maxBaseController = TextEditingController(); - final priorityFeeController = TextEditingController(); + final maxFeePerGasController = TextEditingController(); + final maxPriorityFeePerGasController = TextEditingController(); final gasLimitController = TextEditingController(); - final maxBaseFocus = FocusNode(); - final priorityFeeFocus = FocusNode(); + final maxFeePerGasFocus = FocusNode(); + final maxPriorityFeePerGasFocus = FocusNode(); final gasLimitFocus = FocusNode(); late int _gasLimitCache; + late Decimal _maxFeePerGasGwei; + late Decimal _maxPriorityFeePerGasGwei; + bool _maxFeePerGasIsValid = false; + bool _maxPriorityFeePerGasIsValid = false; + bool _gasLimitIsValid = true; EthEIP1559Fee get _current => EthEIP1559Fee( - maxBaseFeeGwei: Decimal.tryParse(maxBaseController.text) ?? Decimal.zero, - priorityFeeGwei: - Decimal.tryParse(priorityFeeController.text) ?? Decimal.zero, - gasLimit: int.parse(gasLimitController.text), + maxFeePerGasGwei: _maxFeePerGasGwei, + maxPriorityFeePerGasGwei: _maxPriorityFeePerGasGwei, + gasLimit: _gasLimitCache, ); + // Blank or separator-only input is invalid, not zero: a zero max fee cannot + // cover a positive network base fee and would fail at build time. + Amount? _parseFeeInput(String value) { + return Amount.tryParseEditableAmount( + value, + locale: widget.locale, + fractionDigits: 9, + ); + } + + void _maxFeePerGasChanged(String value) { + final amount = _parseFeeInput(value); + setState(() { + _maxFeePerGasIsValid = amount != null && amount.raw > BigInt.zero; + if (amount != null) { + _maxFeePerGasGwei = amount.decimal; + } + }); + _notifyStateChanged(); + } + + void _maxPriorityFeePerGasChanged(String value) { + final amount = _parseFeeInput(value); + setState(() { + _maxPriorityFeePerGasIsValid = + amount != null && amount.raw >= BigInt.zero; + if (amount != null) { + _maxPriorityFeePerGasGwei = amount.decimal; + } + }); + _notifyStateChanged(); + } + + bool get _feeCapsAreConsistent => + _maxFeePerGasGwei >= _maxPriorityFeePerGasGwei; + + void _notifyStateChanged() { + widget.stateChanged( + _maxFeePerGasIsValid && + _maxPriorityFeePerGasIsValid && + _feeCapsAreConsistent && + _gasLimitIsValid + ? _current + : null, + ); + } + String _currentBase = "Current: "; String _currentPriority = "Current: "; + ({Decimal base, Decimal lowPriority, Decimal highPriority})? _gasOracleFees; + + void _updateGasOracleLabels() { + final fees = _gasOracleFees; + if (fees == null) return; + + final currentBaseFee = Amount.formatFixedDecimal( + fees.base, + fractionDigits: 3, + locale: widget.locale, + ); + final lowPriorityFee = Amount.formatFixedDecimal( + fees.lowPriority, + fractionDigits: 3, + locale: widget.locale, + ); + final highPriorityFee = Amount.formatFixedDecimal( + fees.highPriority, + fractionDigits: 3, + locale: widget.locale, + ); + _currentBase = "Current: $currentBaseFee GWEI"; + _currentPriority = "Current: $lowPriorityFee - $highPriorityFee GWEI"; + } + + @override + void didUpdateWidget(EthFeeForm oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.locale != widget.locale) { + relocalizeAmountController( + maxFeePerGasController, + sourceLocale: oldWidget.locale, + targetLocale: widget.locale, + ); + relocalizeAmountController( + maxPriorityFeePerGasController, + sourceLocale: oldWidget.locale, + targetLocale: widget.locale, + ); + _updateGasOracleLabels(); + } + } void _checkNetworkGas() async { final gas = await EthereumAPI.getGasOracle(); - if (mounted) { + if (mounted && gas.value != null) { + final fees = ( + base: gas.value!.suggestBaseFee, + lowPriority: gas.value!.lowPriority, + highPriority: gas.value!.highPriority, + ); setState(() { - _currentBase = - "Current: ${gas.value!.suggestBaseFee.toStringAsFixed(3)} GWEI"; - _currentPriority = - "Current: ${gas.value!.lowPriority.toStringAsFixed(3)} - ${gas.value!.highPriority.toStringAsFixed(3)} GWEI"; + _gasOracleFees = fees; + _updateGasOracleLabels(); }); } } @@ -107,12 +215,27 @@ class _EthFeeFormState extends State { ); }); - maxBaseController.text = - widget.initialState?.maxBaseFeeGwei.toString() ?? ""; - priorityFeeController.text = - widget.initialState?.priorityFeeGwei.toString() ?? ""; - _gasLimitCache = widget.initialState?.gasLimit ?? widget.minGasLimit; + _maxFeePerGasGwei = widget.initialState?.maxFeePerGasGwei ?? Decimal.zero; + _maxPriorityFeePerGasGwei = + widget.initialState?.maxPriorityFeePerGasGwei ?? Decimal.zero; + _maxFeePerGasIsValid = + widget.initialState != null && + widget.initialState!.maxFeePerGasGwei > Decimal.zero; + _maxPriorityFeePerGasIsValid = + widget.initialState != null && + widget.initialState!.maxPriorityFeePerGasGwei >= Decimal.zero; + final maxFeePerGas = widget.initialState?.maxFeePerGasGwei; + final maxPriorityFeePerGas = widget.initialState?.maxPriorityFeePerGasGwei; + maxFeePerGasController.text = maxFeePerGas == null + ? "" + : Amount.formatEditableDecimal(maxFeePerGas, locale: widget.locale); + maxPriorityFeePerGasController.text = maxPriorityFeePerGas == null + ? "" + : Amount.formatEditableDecimal( + maxPriorityFeePerGas, + locale: widget.locale, + ); gasLimitController.text = _gasLimitCache.toString(); } @@ -120,11 +243,11 @@ class _EthFeeFormState extends State { void dispose() { _gasTimer?.cancel(); _gasTimer = null; - maxBaseController.dispose(); - priorityFeeController.dispose(); + maxFeePerGasController.dispose(); + maxPriorityFeePerGasController.dispose(); gasLimitController.dispose(); - maxBaseFocus.dispose(); - priorityFeeFocus.dispose(); + maxFeePerGasFocus.dispose(); + maxPriorityFeePerGasFocus.dispose(); gasLimitFocus.dispose(); super.dispose(); @@ -132,58 +255,53 @@ class _EthFeeFormState extends State { @override Widget build(BuildContext context) { + final fieldStyle = Util.isDesktop + ? STextStyles.desktopTextExtraSmall(context).copyWith( + color: Theme.of( + context, + ).extension()!.textFieldActiveText, + height: 1.8, + ) + : STextStyles.field(context); + final fieldContentPadding = EdgeInsets.only( + left: 16, + top: Util.isDesktop ? 11 : 6, + bottom: Util.isDesktop ? 12 : 8, + right: 5, + ); + return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text("Max base fee (GWEI)", style: STextStyles.smallMed12(context)), + Text("Max fee per gas (GWEI)", style: STextStyles.smallMed12(context)), const SizedBox(height: 10), - ClipRRect( - borderRadius: BorderRadius.circular( - Constants.size.circularBorderRadius, - ), - child: TextField( - minLines: 1, - maxLines: 1, - controller: maxBaseController, - readOnly: false, - autocorrect: false, - enableSuggestions: false, - keyboardType: const TextInputType.numberWithOptions(decimal: true), - focusNode: maxBaseFocus, - onChanged: (value) { - widget.stateChanged(_current); - }, - style: - Util.isDesktop - ? STextStyles.desktopTextExtraSmall(context).copyWith( - color: - Theme.of( - context, - ).extension()!.textFieldActiveText, - height: 1.8, - ) - : STextStyles.field(context), - decoration: standardInputDecoration( - null, - maxBaseFocus, - context, - desktopMed: Util.isDesktop, - ).copyWith( - contentPadding: EdgeInsets.only( - left: 16, - top: Util.isDesktop ? 11 : 6, - bottom: Util.isDesktop ? 12 : 8, - right: 5, - ), + AdaptiveTextField( + textFieldKey: const Key("ethMaxFeePerGasField"), + minLines: 1, + maxLines: 1, + controller: maxFeePerGasController, + readOnly: false, + autocorrect: false, + enableSuggestions: false, + keyboardType: const TextInputType.numberWithOptions(decimal: true), + inputFormatters: [ + AmountInputFormatter( + controller: maxFeePerGasController, + decimals: 9, + locale: widget.locale, ), - ), + ], + focusNode: maxFeePerGasFocus, + onChanged: _maxFeePerGasChanged, + style: fieldStyle, + desktopMed: Util.isDesktop, + contentPadding: fieldContentPadding, ), const SizedBox(height: 6), AnimatedSwitcher( duration: _textFadeDuration, - transitionBuilder: - (child, animation) => - FadeTransition(opacity: animation, child: child), + transitionBuilder: (child, animation) => + FadeTransition(opacity: animation, child: child), child: Text( _currentBase, key: ValueKey( @@ -193,55 +311,44 @@ class _EthFeeFormState extends State { ), ), const SizedBox(height: 20), - Text("Priority fee (GWEI)", style: STextStyles.smallMed12(context)), + Text( + "Max priority fee per gas (GWEI)", + style: STextStyles.smallMed12(context), + ), const SizedBox(height: 10), - ClipRRect( - borderRadius: BorderRadius.circular( - Constants.size.circularBorderRadius, - ), - child: TextField( - minLines: 1, - maxLines: 1, - controller: priorityFeeController, - readOnly: false, - autocorrect: false, - enableSuggestions: false, - keyboardType: const TextInputType.numberWithOptions(decimal: true), - focusNode: priorityFeeFocus, - onChanged: (value) { - widget.stateChanged(_current); - }, - style: - Util.isDesktop - ? STextStyles.desktopTextExtraSmall(context).copyWith( - color: - Theme.of( - context, - ).extension()!.textFieldActiveText, - height: 1.8, - ) - : STextStyles.field(context), - decoration: standardInputDecoration( - null, - priorityFeeFocus, - context, - desktopMed: Util.isDesktop, - ).copyWith( - contentPadding: EdgeInsets.only( - left: 16, - top: Util.isDesktop ? 11 : 6, - bottom: Util.isDesktop ? 12 : 8, - right: 5, - ), + AdaptiveTextField( + textFieldKey: const Key("ethMaxPriorityFeePerGasField"), + minLines: 1, + maxLines: 1, + controller: maxPriorityFeePerGasController, + readOnly: false, + autocorrect: false, + enableSuggestions: false, + keyboardType: const TextInputType.numberWithOptions(decimal: true), + inputFormatters: [ + AmountInputFormatter( + controller: maxPriorityFeePerGasController, + decimals: 9, + locale: widget.locale, ), - ), + ], + focusNode: maxPriorityFeePerGasFocus, + onChanged: _maxPriorityFeePerGasChanged, + style: fieldStyle, + desktopMed: Util.isDesktop, + contentPadding: fieldContentPadding, + errorText: + _maxFeePerGasIsValid && + _maxPriorityFeePerGasIsValid && + !_feeCapsAreConsistent + ? "Max priority fee must not exceed max fee" + : null, ), const SizedBox(height: 6), AnimatedSwitcher( duration: _textFadeDuration, - transitionBuilder: - (child, animation) => - FadeTransition(opacity: animation, child: child), + transitionBuilder: (child, animation) => + FadeTransition(opacity: animation, child: child), child: Text( _currentPriority, key: ValueKey( @@ -253,56 +360,37 @@ class _EthFeeFormState extends State { const SizedBox(height: 20), Text("Gas limit", style: STextStyles.smallMed12(context)), const SizedBox(height: 10), - ClipRRect( - borderRadius: BorderRadius.circular( - Constants.size.circularBorderRadius, - ), - child: TextField( - minLines: 1, - maxLines: 1, - controller: gasLimitController, - readOnly: false, - autocorrect: false, - enableSuggestions: false, - keyboardType: const TextInputType.numberWithOptions(decimal: true), - focusNode: gasLimitFocus, - onChanged: (value) { - final intValue = int.tryParse(value); - if (intValue == null || - intValue < widget.minGasLimit || - intValue > widget.maxGasLimit) { - gasLimitController.text = _gasLimitCache.toString(); - return; + AdaptiveTextField( + textFieldKey: const Key("ethFeeGasLimitField"), + minLines: 1, + maxLines: 1, + controller: gasLimitController, + readOnly: false, + autocorrect: false, + enableSuggestions: false, + keyboardType: TextInputType.number, + focusNode: gasLimitFocus, + onChanged: (value) { + final intValue = tryParseIntegerInput( + value, + minimum: widget.minGasLimit, + maximum: widget.maxGasLimit, + ); + setState(() { + _gasLimitIsValid = intValue != null; + if (intValue != null) { + _gasLimitCache = intValue; } - - _gasLimitCache = intValue; - - widget.stateChanged(_current); - }, - style: - Util.isDesktop - ? STextStyles.desktopTextExtraSmall(context).copyWith( - color: - Theme.of( - context, - ).extension()!.textFieldActiveText, - height: 1.8, - ) - : STextStyles.field(context), - decoration: standardInputDecoration( - null, - gasLimitFocus, - context, - desktopMed: Util.isDesktop, - ).copyWith( - contentPadding: EdgeInsets.only( - left: 16, - top: Util.isDesktop ? 11 : 6, - bottom: Util.isDesktop ? 12 : 8, - right: 5, - ), - ), - ), + }); + _notifyStateChanged(); + }, + style: fieldStyle, + desktopMed: Util.isDesktop, + contentPadding: fieldContentPadding, + errorText: _gasLimitIsValid + ? null + : "Enter a whole number from " + "${widget.minGasLimit} to ${widget.maxGasLimit}", ), ], ); diff --git a/lib/widgets/textfields/adaptive_text_field.dart b/lib/widgets/textfields/adaptive_text_field.dart index 8963ce3cae..569818d22f 100644 --- a/lib/widgets/textfields/adaptive_text_field.dart +++ b/lib/widgets/textfields/adaptive_text_field.dart @@ -12,11 +12,14 @@ import '../textfield_icon_button.dart'; class AdaptiveTextField extends StatefulWidget { const AdaptiveTextField({ super.key, + this.textFieldKey, this.labelText, this.hintText, this.controller, this.focusNode, + this.style, this.autocorrect, + this.desktopMed = false, this.readOnly = false, this.enabled = true, this.enableSuggestions = true, @@ -35,12 +38,15 @@ class AdaptiveTextField extends StatefulWidget { this.keyboardType, }); + final Key? textFieldKey; final String? labelText; final String? hintText; final TextEditingController? controller; final FocusNode? focusNode; + final TextStyle? style; final bool? autocorrect; + final bool desktopMed; final EdgeInsets? contentPadding; final int? minLines; final int? maxLines; @@ -127,11 +133,14 @@ class _AdaptiveTextFieldState extends State { Constants.size.circularBorderRadius, ), child: TextField( + key: widget.textFieldKey, minLines: widget.minLines, maxLines: widget.maxLines, - style: Util.isDesktop - ? STextStyles.field(context).copyWith(fontSize: 16) - : STextStyles.field(context), + style: + widget.style ?? + (Util.isDesktop + ? STextStyles.field(context).copyWith(fontSize: 16) + : STextStyles.field(context)), controller: controller, focusNode: _focusNode, onChanged: widget.onChanged, @@ -148,6 +157,7 @@ class _AdaptiveTextFieldState extends State { widget.labelText, _focusNode, context, + desktopMed: widget.desktopMed, ).copyWith( alignLabelWithHint: (widget.minLines ?? 1) > 2 ? true : null, hintText: widget.hintText, @@ -192,7 +202,24 @@ class _AdaptiveTextFieldState extends State { if (data?.text != null && data!.text!.isNotEmpty) { final content = data.text!.trim(); - controller.text = content; + // Setting controller.text directly skips + // inputFormatters, so run them here as a + // paste into the (empty) field would. + TextEditingValue value = TextEditingValue( + text: content, + selection: TextSelection.collapsed( + offset: content.length, + ), + ); + for (final formatter + in widget.inputFormatters ?? + const []) { + value = formatter.formatEditUpdate( + TextEditingValue.empty, + value, + ); + } + controller.text = value.text; } } else { controller.text = ""; diff --git a/lib/widgets/textfields/exchange_textfield.dart b/lib/widgets/textfields/exchange_textfield.dart index 3f6dfc3cab..15853cda82 100644 --- a/lib/widgets/textfields/exchange_textfield.dart +++ b/lib/widgets/textfields/exchange_textfield.dart @@ -124,29 +124,22 @@ class _ExchangeTextFieldState extends ConsumerState { decimal: true, ), decoration: InputDecoration( - contentPadding: const EdgeInsets.only( - top: 12, - left: 12, - ), + contentPadding: const EdgeInsets.only(top: 12, left: 12), hintText: widget.currency == null ? "select currency" : "0", - hintStyle: STextStyles.fieldLabel(context).copyWith( - fontSize: 14, - ), + hintStyle: STextStyles.fieldLabel( + context, + ).copyWith(fontSize: 14), ), inputFormatters: [ AmountInputFormatter( - decimals: 8, // todo change this + controller: controller, + decimals: 8, locale: ref.watch( - localeServiceChangeNotifierProvider - .select((value) => value.locale), + localeServiceChangeNotifierProvider.select( + (value) => value.locale, + ), ), ), - // // regex to validate a crypto amount with 8 decimal places - // TextInputFormatter.withFunction((oldValue, newValue) => - // RegExp(r'^([0-9]*[,.]?[0-9]{0,8}|[,.][0-9]{0,8})$') - // .hasMatch(newValue.text) - // ? newValue - // : oldValue), ], ), ), @@ -158,15 +151,11 @@ class _ExchangeTextFieldState extends ConsumerState { decoration: BoxDecoration( color: buttonColor, borderRadius: BorderRadius.horizontal( - right: Radius.circular( - borderRadius, - ), + right: Radius.circular(borderRadius), ), ), child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 16, - ), + padding: const EdgeInsets.symmetric(horizontal: 16), child: Row( children: [ Container( @@ -203,14 +192,10 @@ class _ExchangeTextFieldState extends ConsumerState { color: Theme.of(context) .extension()! .textFieldDefaultBG, - borderRadius: BorderRadius.circular( - 18, - ), + borderRadius: BorderRadius.circular(18), ), child: ClipRRect( - borderRadius: BorderRadius.circular( - 18, - ), + borderRadius: BorderRadius.circular(18), child: const LoadingIndicator(), ), ), @@ -237,29 +222,24 @@ class _ExchangeTextFieldState extends ConsumerState { }, ), ), - const SizedBox( - width: 6, - ), + const SizedBox(width: 6), Text( widget.currency?.ticker.toUpperCase() ?? "n/a", style: STextStyles.smallMed14(context).copyWith( - color: Theme.of(context) - .extension()! - .textDark, + color: Theme.of( + context, + ).extension()!.textDark, ), ), - if (!widget.isWalletCoin) - const SizedBox( - width: 6, - ), + if (!widget.isWalletCoin) const SizedBox(width: 6), if (!widget.isWalletCoin) SvgPicture.asset( Assets.svg.chevronDown, width: 5, height: 2.5, - color: Theme.of(context) - .extension()! - .textDark, + color: Theme.of( + context, + ).extension()!.textDark, ), ], ), diff --git a/pubspec.lock b/pubspec.lock index 2c042130bd..4d1e1fcf4b 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -252,10 +252,10 @@ packages: dependency: "direct main" description: name: camera_macos - sha256: a0e15729caf4e7c2831b9cd964e8c2e2ea985cd816e56316be03355de44aa743 + sha256: "64e199368efb0dc12c5298819df98aada4fe2cc50a5d84997f7bf7d94edaa3f8" url: "https://pub.dev" source: hosted - version: "0.0.9" + version: "0.1.1" camera_platform_interface: dependency: "direct main" description: @@ -376,18 +376,18 @@ packages: dependency: "direct main" description: name: connectivity_plus - sha256: "77a180d6938f78ca7d2382d2240eb626c0f6a735d0bfdce227d8ffb80f95c48b" + sha256: "762c99f890ca8bf87f7337236f99edd42793843bc6c3631da294a76653a54bd0" url: "https://pub.dev" source: hosted - version: "4.0.2" + version: "7.3.1" connectivity_plus_platform_interface: dependency: transitive description: name: connectivity_plus_platform_interface - sha256: cf1d1c28f4416f8c654d7dc3cd638ec586076255d407cef3ddbdaf178272a71a + sha256: "3c09627c536d22fd24691a905cdd8b14520de69da52c7a97499c8be5284a32ed" url: "https://pub.dev" source: hosted - version: "1.2.4" + version: "2.1.0" convert: dependency: "direct main" description: @@ -777,18 +777,18 @@ packages: dependency: "direct main" description: name: desktop_drop - sha256: d55a010fe46c8e8fcff4ea4b451a9ff84a162217bdb3b2a0aa1479776205e15d + sha256: aa1e797255bfbc76f9eb5aa4f61e5b68dbf69962ab1be6495816d2f251bc0d1f url: "https://pub.dev" source: hosted - version: "0.4.4" + version: "0.7.1" device_info_plus: dependency: "direct main" description: name: device_info_plus - sha256: a7fd703482b391a87d60b6061d04dfdeab07826b96f9abd8f5ed98068acc0074 + sha256: b4fed1b2835da9d670d7bed7db79ae2a94b0f5ad6312268158a9b5479abbacdd url: "https://pub.dev" source: hosted - version: "10.1.2" + version: "12.4.0" device_info_plus_platform_interface: dependency: transitive description: @@ -1036,26 +1036,42 @@ packages: dependency: "direct main" description: name: flutter_local_notifications - sha256: "674173fd3c9eda9d4c8528da2ce0ea69f161577495a9cc835a2a4ecd7eadeb35" + sha256: "1447ba911c60f2ba3f25dae1af151ec187162566b0f57e37771bf0b400f013ad" url: "https://pub.dev" source: hosted - version: "17.2.4" + version: "22.3.0" flutter_local_notifications_linux: dependency: transitive description: name: flutter_local_notifications_linux - sha256: c49bd06165cad9beeb79090b18cd1eb0296f4bf4b23b84426e37dd7c027fc3af + sha256: "9ca97e63776f29ab1b955725c09999fc2c150523269db150c39274f2a43c5a8b" url: "https://pub.dev" source: hosted - version: "4.0.1" + version: "8.0.1" flutter_local_notifications_platform_interface: dependency: transitive description: name: flutter_local_notifications_platform_interface - sha256: "85f8d07fe708c1bdcf45037f2c0109753b26ae077e9d9e899d55971711a4ea66" + sha256: "43c3761d916c9bd3d5c7ebbc44d82f4990329840c0c5d62ad5260cc1b5d399bd" + url: "https://pub.dev" + source: hosted + version: "12.2.0" + flutter_local_notifications_web: + dependency: transitive + description: + name: flutter_local_notifications_web + sha256: "516afaf97a2d1e67a036c6617321b00d205d72f7a67b6eccf936cd565f985878" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + flutter_local_notifications_windows: + dependency: transitive + description: + name: flutter_local_notifications_windows + sha256: "6f43bdd03b171b7a90f22647506fea33e2bb12294b7c7c7a3d690e960a382945" url: "https://pub.dev" source: hosted - version: "7.2.0" + version: "3.1.1" flutter_mwebd: dependency: "direct main" description: @@ -1100,50 +1116,50 @@ packages: dependency: "direct main" description: name: flutter_secure_storage - sha256: "22dbf16f23a4bcf9d35e51be1c84ad5bb6f627750565edd70dab70f3ff5fff8f" + sha256: "7686b1d6a29985dcbb808c59518226e603e3bfa7c0ddfd1a0d00e4cda77c868e" url: "https://pub.dev" source: hosted - version: "8.1.0" - flutter_secure_storage_linux: + version: "10.3.1" + flutter_secure_storage_darwin: dependency: transitive description: - name: flutter_secure_storage_linux - sha256: be76c1d24a97d0b98f8b54bce6b481a380a6590df992d0098f868ad54dc8f688 + name: flutter_secure_storage_darwin + sha256: "82329fa5cdf343773b1b6897dea959105a29f092454259edff92f9f6637e8149" url: "https://pub.dev" source: hosted - version: "1.2.3" - flutter_secure_storage_macos: + version: "0.3.2" + flutter_secure_storage_linux: dependency: transitive description: - name: flutter_secure_storage_macos - sha256: "6c0a2795a2d1de26ae202a0d78527d163f4acbb11cde4c75c670f3a0fc064247" + name: flutter_secure_storage_linux + sha256: "76fa9c841b3b1619fc5b5bc36efc7d158fa2356f223b6caeb1d0c80a54168546" url: "https://pub.dev" source: hosted - version: "3.1.3" + version: "3.0.2" flutter_secure_storage_platform_interface: dependency: transitive description: name: flutter_secure_storage_platform_interface - sha256: cf91ad32ce5adef6fba4d736a542baca9daf3beac4db2d04be350b87f69ac4a8 + sha256: "788060052712555182aba55ecb5f8b6e5cb9cfe8f776c83249a61fe3ce877db4" url: "https://pub.dev" source: hosted - version: "1.1.2" + version: "2.0.3" flutter_secure_storage_web: dependency: transitive description: name: flutter_secure_storage_web - sha256: f4ebff989b4f07b2656fb16b47852c0aab9fed9b4ec1c70103368337bc1886a9 + sha256: "073a62b3aeb866ab4ce795f960413948e51e5a42a9b0c8333b6daf5bb3208a1c" url: "https://pub.dev" source: hosted - version: "1.2.1" + version: "2.1.1" flutter_secure_storage_windows: dependency: transitive description: name: flutter_secure_storage_windows - sha256: "38f9501c7cb6f38961ef0e1eacacee2b2d4715c63cc83fe56449c4d3d0b47255" + sha256: "3b7c8e068875dfd46719ff57c90d8c459c87f2302ed6b00ff006b3c9fcad1613" url: "https://pub.dev" source: hosted - version: "2.1.1" + version: "4.1.0" flutter_svg: dependency: "direct main" description: @@ -1429,7 +1445,7 @@ packages: source: hosted version: "4.12.0" json_rpc_2: - dependency: "direct overridden" + dependency: transitive description: name: json_rpc_2 sha256: "82dfd37d3b2e5030ae4729e1d7f5538cbc45eb1c73d618b9272931facac3bec1" @@ -1780,50 +1796,50 @@ packages: dependency: "direct main" description: name: permission_handler - sha256: bc917da36261b00137bbc8896bf1482169cd76f866282368948f032c8c1caae1 + sha256: e7317eb2eb611d1bf0386b6b974be9c50449f975f19a90a7b8ea013550302b30 url: "https://pub.dev" source: hosted - version: "12.0.1" + version: "13.0.1" permission_handler_android: dependency: transitive description: name: permission_handler_android - sha256: "1e3bc410ca1bf84662104b100eb126e066cb55791b7451307f9708d4007350e6" + sha256: d7676c6fcf2f0b92537ec41476a6ead45a00b0d8bbb852395a6f9f33f49d6242 url: "https://pub.dev" source: hosted - version: "13.0.1" + version: "14.0.0" permission_handler_apple: dependency: transitive description: name: permission_handler_apple - sha256: f000131e755c54cf4d84a5d8bd6e4149e262cc31c5a8b1d698de1ac85fa41023 + sha256: f49cb15a064ea9d974fc7fbb302099353b7b170d07284e86e264561579e5bcf8 url: "https://pub.dev" source: hosted - version: "9.4.7" + version: "9.6.1" permission_handler_html: dependency: transitive description: name: permission_handler_html - sha256: "38f000e83355abb3392140f6bc3030660cfaef189e1f87824facb76300b4ff24" + sha256: "6ea98b3f17f60d3b527f2647ed2ab4dc0f6bfe25b22cb1c363f5d8f62252f6ac" url: "https://pub.dev" source: hosted - version: "0.1.3+5" + version: "0.1.4+1" permission_handler_platform_interface: dependency: transitive description: name: permission_handler_platform_interface - sha256: eb99b295153abce5d683cac8c02e22faab63e50679b937fa1bf67d58bb282878 + sha256: a5c8a97ecf5616112a5b16d4b8e9ec0e5ae90ef63ac69c0d7b8ae240be760b23 url: "https://pub.dev" source: hosted - version: "4.3.0" + version: "4.4.0" permission_handler_windows: dependency: transitive description: name: permission_handler_windows - sha256: "1a790728016f79a41216d88672dbc5df30e686e811ad4e698bfc51f76ad91f1e" + sha256: caeae01858a0a7d2df67a445ac98e1ad95e55a0e77c73044f4e9b1c8c2289cbd url: "https://pub.dev" source: hosted - version: "0.2.1" + version: "0.2.2" petitparser: dependency: transitive description: @@ -2012,18 +2028,18 @@ packages: dependency: "direct main" description: name: share_plus - sha256: "3ef39599b00059db0990ca2e30fca0a29d8b37aae924d60063f8e0184cf20900" + sha256: "223873d106614442ea6f20db5a038685cc5b32a2fba81cdecaefbbae0523f7fa" url: "https://pub.dev" source: hosted - version: "7.2.2" + version: "12.0.2" share_plus_platform_interface: dependency: transitive description: name: share_plus_platform_interface - sha256: "251eb156a8b5fa9ce033747d73535bf53911071f8d3b6f4f0b578505ce0d4496" + sha256: "88023e53a13429bd65d8e85e11a9b484f49d4c190abbd96c7932b74d6927cc9a" url: "https://pub.dev" source: hosted - version: "3.4.0" + version: "6.1.0" shelf: dependency: transitive description: @@ -2276,10 +2292,10 @@ packages: dependency: transitive description: name: timezone - sha256: "2236ec079a174ce07434e89fcd3fcda430025eb7692244139a9cf54fdcf1fc7d" + sha256: "981d1020d6ef8fe1e7b3de5054e5b25579ae7c403d7734adc508ffc47668e9cb" url: "https://pub.dev" source: hosted - version: "0.9.4" + version: "0.11.1" timing: dependency: transitive description: @@ -2337,6 +2353,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.1" + universal_platform: + dependency: transitive + description: + name: universal_platform + sha256: "64e16458a0ea9b99260ceb5467a214c1f298d647c659af1bff6d3bf82536b1ec" + url: "https://pub.dev" + source: hosted + version: "1.1.0" unorm_dart: dependency: "direct main" description: @@ -2465,14 +2489,6 @@ packages: url: "https://pub.dev" source: hosted version: "15.0.2" - wakelock_platform_interface: - dependency: transitive - description: - name: wakelock_platform_interface - sha256: "1f4aeb81fb592b863da83d2d0f7b8196067451e4df91046c26b54a403f9de621" - url: "https://pub.dev" - source: hosted - version: "0.3.0" wakelock_plus: dependency: "direct main" description: @@ -2489,15 +2505,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" - wakelock_windows: - dependency: "direct overridden" - description: - path: wakelock_windows - ref: "2a9bca63a540771f241d688562351482b2cf234c" - resolved-ref: "2a9bca63a540771f241d688562351482b2cf234c" - url: "https://github.com/diegotori/wakelock" - source: git - version: "0.2.2" wallet: dependency: "direct main" description: @@ -2571,7 +2578,7 @@ packages: source: hosted version: "1.2.1" win32: - dependency: "direct overridden" + dependency: transitive description: name: win32 sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e @@ -2582,10 +2589,10 @@ packages: dependency: transitive description: name: win32_registry - sha256: "21ec76dfc731550fd3e2ce7a33a9ea90b828fdf19a5c3bcf556fa992cfa99852" + sha256: "6f1b564492d0147b330dd794fee8f512cec4977957f310f9951b5f9d83618dae" url: "https://pub.dev" source: hosted - version: "1.1.5" + version: "2.1.0" window_size: dependency: "direct main" description: diff --git a/scripts/app_config/templates/android/app/build.gradle b/scripts/app_config/templates/android/app/build.gradle index 8fee783991..6d79983c7c 100644 --- a/scripts/app_config/templates/android/app/build.gradle +++ b/scripts/app_config/templates/android/app/build.gradle @@ -13,7 +13,7 @@ if (keystorePropertiesFile.exists()) { android { namespace "com.place.holder" - compileSdk flutter.compileSdkVersion + compileSdk 37 // ndkVersion flutter.ndkVersion ndkVersion = "28.2.13676358" @@ -42,7 +42,7 @@ android { } dependencies { - coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.4") + coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4") } // No ndk.abiFilters here: AGP rejects it alongside the abi splits set @@ -88,7 +88,7 @@ android { tasks.whenTaskAdded { task -> if (task.name == 'assembleDebug') { task.doFirst { - println "The compileSdkVersion is $flutter.compileSdkVersion" + println "The compileSdkVersion is $android.compileSdk" println "The targetSdkVersion is $flutter.targetSdkVersion" println "The ndkVersion is $ndkVersion" } diff --git a/scripts/app_config/templates/linux/CMakeLists.txt b/scripts/app_config/templates/linux/CMakeLists.txt index d1c69c17fe..d36efad397 100644 --- a/scripts/app_config/templates/linux/CMakeLists.txt +++ b/scripts/app_config/templates/linux/CMakeLists.txt @@ -193,6 +193,12 @@ foreach(bundled_library ${PLUGIN_BUNDLED_LIBRARIES}) COMPONENT Runtime) endforeach(bundled_library) +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/linux/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + # Fully re-copy the assets directory on each build to avoid having stale files # from a previous install. set(FLUTTER_ASSET_DIR_NAME "flutter_assets") diff --git a/scripts/app_config/templates/macos/Runner.xcodeproj/project.pbxproj b/scripts/app_config/templates/macos/Runner.xcodeproj/project.pbxproj index d6cb54f868..d5b9e5fc04 100644 --- a/scripts/app_config/templates/macos/Runner.xcodeproj/project.pbxproj +++ b/scripts/app_config/templates/macos/Runner.xcodeproj/project.pbxproj @@ -543,7 +543,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.14; + MACOSX_DEPLOYMENT_TARGET = 10.15; MTL_ENABLE_DEBUG_INFO = NO; ONLY_ACTIVE_ARCH = YES; SDKROOT = macosx; @@ -646,7 +646,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.14; + MACOSX_DEPLOYMENT_TARGET = 10.15; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = macosx; @@ -694,7 +694,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.14; + MACOSX_DEPLOYMENT_TARGET = 10.15; MTL_ENABLE_DEBUG_INFO = NO; ONLY_ACTIVE_ARCH = YES; SDKROOT = macosx; diff --git a/scripts/app_config/templates/pubspec.template.yaml b/scripts/app_config/templates/pubspec.template.yaml index cd53bf1fad..24a873ba35 100644 --- a/scripts/app_config/templates/pubspec.template.yaml +++ b/scripts/app_config/templates/pubspec.template.yaml @@ -115,8 +115,8 @@ dependencies: # Utility plugins http: ^1.6.0 local_auth: ^2.3.0 - permission_handler: ^12.0.0+1 - flutter_local_notifications: ^17.2.2 + permission_handler: ^13.0.1 + flutter_local_notifications: ^22.3.0 zxcvbn: ^1.0.0 dart_numerics: ^0.0.6 @@ -152,7 +152,7 @@ dependencies: ref: 6a5d3d69e54c175ae44b44040fb2743c9b6405a6 # Storage plugins - flutter_secure_storage: ^8.0.0 + flutter_secure_storage: ^10.3.1 hive_ce: ^2.13.2 hive_ce_flutter: ^2.3.2 path_provider: ^2.1.5 @@ -171,19 +171,19 @@ dependencies: intl: ^0.19.0 html: ^0.15.6 devicelocale: 0.9.1 - device_info_plus: ^10.1.2 + device_info_plus: ^12.4.0 keyboard_dismisser: ^3.0.0 another_flushbar: ^1.10.28 tuple: ^2.0.0 flutter_riverpod: ^1.0.3 qr_flutter: ^4.0.0 - share_plus: ^7.0.2 + share_plus: ^12.0.2 emojis: ^0.9.9 pointycastle: ^4.0.0 package_info_plus: ^8.0.2 lottie: ^3.3.2 file_picker: ^10.3.3 - connectivity_plus: ^4.0.1 + connectivity_plus: ^7.3.1 isar_community: 3.3.0-dev.2 isar_community_flutter_libs: 3.3.0-dev.2 dropdown_button2: ^2.1.3 @@ -197,7 +197,7 @@ dependencies: ref: bed60e43e4e509ea45bb097e6caee9f8293ddf98 hex: ^0.2.0 archive: ^4.0.2 - desktop_drop: ^0.4.4 + desktop_drop: ^0.7.1 nanodart: git: url: https://github.com/cypherstack/nanodart @@ -242,7 +242,7 @@ dependencies: url: https://github.com/cypherstack/packages.git path: packages/camera/camera_windows camera_platform_interface: ^2.8.0 - camera_macos: ^0.0.8 + camera_macos: ^0.1.1 blockchain_utils: ^3.3.0 on_chain: ^4.0.1 cbor: ^6.3.3 @@ -305,12 +305,9 @@ dependency_overrides: url: https://github.com/cypherstack/logger ref: 3c0cba27868ebb5c7d65ebc30a8e6e5342186692 - # required to make devicelocale work + # required to make web socket channel work (solana) web: ^0.5.0 - # needed for dart 3.5+ (at least for now) - win32: ^5.5.4 - # coinlib_flutter requires this coinlib: git: @@ -318,24 +315,12 @@ dependency_overrides: path: coinlib ref: f0e12dacb6d39e1cb340f0deae178d0fad1d6fd6 - bip47: - git: - url: https://github.com/cypherstack/bip47.git - ref: bdc0c0788d1d6dfb04863a793955f848ba1624a8 - # bip47 pins a different bitcoindart commit; override to ours bitcoindart: git: url: https://github.com/cypherstack/bitcoindart.git ref: ea33b1f5d6a701791359a2e180f73866dc667732 - # required for dart 3, at least until a fix is merged upstream - wakelock_windows: - git: - url: https://github.com/diegotori/wakelock - ref: 2a9bca63a540771f241d688562351482b2cf234c - path: wakelock_windows - # required override for solana, etc bip39: git: @@ -349,7 +334,6 @@ dependency_overrides: analyzer: ">=8.2.0 <8.4.0" # xelis override - json_rpc_2: ^4.0.0 freezed: ^3.1.0 freezed_annotation: ^3.1.0 diff --git a/scripts/app_config/templates/windows/CMakeLists.txt b/scripts/app_config/templates/windows/CMakeLists.txt index a33fe23bb5..d152222b2c 100644 --- a/scripts/app_config/templates/windows/CMakeLists.txt +++ b/scripts/app_config/templates/windows/CMakeLists.txt @@ -99,6 +99,12 @@ if(PLUGIN_BUNDLED_LIBRARIES) COMPONENT Runtime) endif() +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/windows/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + # Fully re-copy the assets directory on each build to avoid having stale files # from a previous install. set(FLUTTER_ASSET_DIR_NAME "flutter_assets") diff --git a/test/address_utils_test.dart b/test/address_utils_test.dart index c3ce3cbad0..dc1bd24e74 100644 --- a/test/address_utils_test.dart +++ b/test/address_utils_test.dart @@ -40,6 +40,78 @@ void main() { expect(result.message, "eggs are good!"); }); + test("parse uri with malformed amount rejects the whole uri", () { + // Payment URI amounts are machine-format plain decimals (BIP21 style): + // no signs, no exponents, no grouping or locale separators, no units. + const malformed = [ + "-5", + "%2B5", // literal "+5"; a raw "+" is query-encoding for a space + "1e3", + "1E3", + "1e-3", + "1.2.3", + "5%20BTC", + "1,220.0", // grouped + "1,5", // comma decimal + "1.220,00", // European format + "1%20220.0", // space grouped + "5.", // trailing separator + "5,", + ".", + "", // explicitly present but empty + "0x10", + "NaN", + "Infinity", + "abc", + ]; + for (final amount in malformed) { + expect( + AddressUtils.parsePaymentUri("bitcoin:$firoAddress?amount=$amount"), + isNull, + reason: "amount=$amount", + ); + } + }); + + test("parse uri with valid amount preserves it verbatim", () { + const valid = [ + "5", + "007", + "1220.0", + "1.220", + "0.5", + ".5", + "0.00000001", + "123456789.123456789", + ]; + for (final amount in valid) { + final result = AddressUtils.parsePaymentUri( + "bitcoin:$firoAddress?amount=$amount", + ); + expect(result?.amount, amount, reason: "amount=$amount"); + } + + // Surrounding whitespace is trimmed, not rejected. + final padded = AddressUtils.parsePaymentUri( + "bitcoin:$firoAddress?amount=%201.5%20", + ); + expect(padded?.amount, "1.5"); + + // A raw "+" in a query decodes to a space, so "+5" arrives as " 5" and + // trims to a valid "5". A literal plus sign (%2B5) is rejected above. + final plusAsSpace = AddressUtils.parsePaymentUri( + "bitcoin:$firoAddress?amount=+5", + ); + expect(plusAsSpace?.amount, "5"); + }); + + test("parse query parameters exactly once", () { + const uri = "bitcoin:$firoAddress?label=Save%25&amount=1.5"; + final result = AddressUtils.parsePaymentUri(uri); + expect(result!.label, "Save%"); + expect(result.amount, "1.5"); + }); + test("parse an invalid uri string", () { const uri = "firo$firoAddress?amount=50&label=eggs"; final result = AddressUtils.parsePaymentUri(uri); @@ -68,6 +140,66 @@ void main() { expect(result.message, "eggs are good!"); }); + test("distinguish CashAddr payment URIs from prefixed addresses", () { + const address = "qpm2qsznhks23z7629mms6s4cwef74vcwvy22gdx6a"; + + for (final scheme in ["bitcoincash", "bchtest", "ecash", "ectest"]) { + expect(AddressUtils.parsePaymentUri("$scheme:$address"), isNull); + + final result = AddressUtils.parsePaymentUri( + "$scheme:$address?amount=1.25", + ); + expect(result?.scheme, scheme); + expect(result?.address, "$scheme:$address"); + expect(result?.amount, "1.25"); + } + + final uppercase = AddressUtils.parsePaymentUri( + "BITCOINCASH:${address.toUpperCase()}?amount=1.25", + ); + expect(uppercase?.address, "bitcoincash:$address"); + + expect(AddressUtils.parsePaymentUri("xel:$address?amount=1.25"), isNull); + + final xelis = AddressUtils.parsePaymentUri( + "xelis:xel:$address?amount=1.25", + ); + expect((xelis?.address, xelis?.amount), ("xel:$address", "1.25")); + }); + + test("parse payment URI memo and destination-tag aliases", () { + const aliases = { + "tx_payment_id": "payment-id", + "memo": "memo-value", + "dt": "12345", + "destination_tag": "destination-tag", + }; + + for (final entry in aliases.entries) { + final result = AddressUtils.parsePaymentUri( + "ripple:$firoAddress?${entry.key}=${entry.value}", + ); + expect(result?.memo, entry.value, reason: entry.key); + } + + final fallback = AddressUtils.parsePaymentUri( + "ripple:$firoAddress?memo=&dt=54321", + ); + expect(fallback?.memo, "54321"); + + // Memo and amount combine. + final combined = AddressUtils.parsePaymentUri( + "ripple:$firoAddress?amount=1.5&dt=12345", + ); + expect((combined?.amount, combined?.memo), ("1.5", "12345")); + + // A malformed amount rejects the whole URI; the memo does not survive. + expect( + AddressUtils.parsePaymentUri("ripple:$firoAddress?amount=1,5&dt=12345"), + isNull, + ); + }); + test("encode a list of (mnemonic) words/strings as a json object", () { final List list = [ "hello", @@ -132,4 +264,38 @@ void main() { "firo:$firoAddress?amount=10.0123&message=Some+kind+of+message%21", ); }); + + test("build a standard payment URI", () { + expect( + AddressUtils.buildPaymentUriString( + scheme: "firo", + address: firoAddress, + amount: "10.0123", + message: "Some kind of message!", + ), + "firo:$firoAddress?amount=10.0123&message=Some+kind+of+message%21", + ); + }); + + test("build Monero-family payment URIs with standard query parameters", () { + for (final scheme in ["monero", "wownero"]) { + final uri = AddressUtils.buildPaymentUriString( + scheme: scheme, + address: firoAddress, + amount: "1.25", + message: "Some kind of message!", + ); + + expect( + uri, + "$scheme:$firoAddress?tx_amount=1.25&" + "tx_description=Some+kind+of+message%21", + ); + expect(uri, isNot(contains("#"))); + + final parsed = AddressUtils.parsePaymentUri(uri); + expect(parsed?.amount, "1.25"); + expect(parsed?.message, "Some kind of message!"); + } + }); } diff --git a/test/cached_electrumx_test.mocks.dart b/test/cached_electrumx_test.mocks.dart index 41d6b0203c..1fd47df1d1 100644 --- a/test/cached_electrumx_test.mocks.dart +++ b/test/cached_electrumx_test.mocks.dart @@ -190,11 +190,16 @@ class MockElectrumXClient extends _i1.Mock implements _i6.ElectrumXClient { as _i9.Future>); @override - _i9.Future ping({String? requestID, int? retryCount = 1}) => + _i9.Future ping({ + String? requestID, + int? retryCount = 1, + Duration? timeout = const Duration(seconds: 30), + }) => (super.noSuchMethod( Invocation.method(#ping, [], { #requestID: requestID, #retryCount: retryCount, + #timeout: timeout, }), returnValue: _i9.Future.value(false), ) diff --git a/test/electrumx_test.dart b/test/electrumx_test.dart index b8c82c8c18..769dbc772f 100644 --- a/test/electrumx_test.dart +++ b/test/electrumx_test.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:io'; import 'package:decimal/decimal.dart'; @@ -11,8 +12,6 @@ import 'package:stackwallet/services/tor_service.dart'; import 'package:stackwallet/utilities/logger.dart'; import 'package:stackwallet/utilities/prefs.dart'; import 'package:stackwallet/utilities/tor_plain_net_option_enum.dart'; -import 'package:stackwallet/wallets/crypto_currency/coins/bitcoin.dart'; -import 'package:stackwallet/wallets/crypto_currency/coins/firo.dart'; import 'package:stackwallet/wallets/crypto_currency/crypto_currency.dart'; import 'sample_data/get_anonymity_set_sample_data.dart'; @@ -217,6 +216,29 @@ void main() { expect(server.requestCount('server.ping'), 1); }); + test('ping timeout returns false', () async { + final response = Completer(); + addTearDown(() { + if (!response.isCompleted) { + response.complete(); + } + }); + final server = registerServer( + handlers: {'server.ping': (_) => response.future}, + ); + final client = buildClient(clearServer: server, coin: bitcoin()); + await client.checkElectrumAdapter(); + + final result = await client.ping( + requestID: 'ping-timeout', + timeout: const Duration(milliseconds: 100), + ); + response.complete(); + + expect(result, isFalse); + expect(server.requestCount('server.ping'), 1); + }); + test('server.features success returns a parsed map', () async { final expected = { 'genesis_hash': 'genesis', diff --git a/test/flutter_secure_storage_interface_test.mocks.dart b/test/flutter_secure_storage_interface_test.mocks.dart index 5d16aaa3e7..209dcc62f8 100644 --- a/test/flutter_secure_storage_interface_test.mocks.dart +++ b/test/flutter_secure_storage_interface_test.mocks.dart @@ -3,8 +3,9 @@ // Do not manually edit this file. // ignore_for_file: no_leading_underscores_for_library_prefixes -import 'dart:async' as _i3; +import 'dart:async' as _i4; +import 'package:flutter/foundation.dart' as _i3; import 'package:flutter_secure_storage/flutter_secure_storage.dart' as _i2; import 'package:mockito/mockito.dart' as _i1; @@ -50,8 +51,8 @@ class _FakeWebOptions_4 extends _i1.SmartFake implements _i2.WebOptions { : super(parent, parentInvocation); } -class _FakeMacOsOptions_5 extends _i1.SmartFake implements _i2.MacOsOptions { - _FakeMacOsOptions_5(Object parent, Invocation parentInvocation) +class _FakeAppleOptions_5 extends _i1.SmartFake implements _i2.AppleOptions { + _FakeAppleOptions_5(Object parent, Invocation parentInvocation) : super(parent, parentInvocation); } @@ -117,25 +118,67 @@ class MockFlutterSecureStorage extends _i1.Mock as _i2.WebOptions); @override - _i2.MacOsOptions get mOptions => + _i2.AppleOptions get mOptions => (super.noSuchMethod( Invocation.getter(#mOptions), - returnValue: _FakeMacOsOptions_5( + returnValue: _FakeAppleOptions_5( this, Invocation.getter(#mOptions), ), ) - as _i2.MacOsOptions); + as _i2.AppleOptions); @override - _i3.Future write({ + Map>> get getListeners => + (super.noSuchMethod( + Invocation.getter(#getListeners), + returnValue: >>{}, + ) + as Map>>); + + @override + void registerListener({ + required String? key, + required _i3.ValueChanged? listener, + }) => super.noSuchMethod( + Invocation.method(#registerListener, [], {#key: key, #listener: listener}), + returnValueForMissingStub: null, + ); + + @override + void unregisterListener({ + required String? key, + required _i3.ValueChanged? listener, + }) => super.noSuchMethod( + Invocation.method(#unregisterListener, [], { + #key: key, + #listener: listener, + }), + returnValueForMissingStub: null, + ); + + @override + void unregisterAllListenersForKey({required String? key}) => + super.noSuchMethod( + Invocation.method(#unregisterAllListenersForKey, [], {#key: key}), + returnValueForMissingStub: null, + ); + + @override + void unregisterAllListeners() => super.noSuchMethod( + Invocation.method(#unregisterAllListeners, []), + returnValueForMissingStub: null, + ); + + @override + _i4.Future write({ required String? key, required String? value, - _i2.IOSOptions? iOptions, + _i2.AppleOptions? iOptions, _i2.AndroidOptions? aOptions, _i2.LinuxOptions? lOptions, _i2.WebOptions? webOptions, - _i2.MacOsOptions? mOptions, + _i2.AppleOptions? mOptions, _i2.WindowsOptions? wOptions, }) => (super.noSuchMethod( @@ -149,19 +192,19 @@ class MockFlutterSecureStorage extends _i1.Mock #mOptions: mOptions, #wOptions: wOptions, }), - returnValue: _i3.Future.value(), - returnValueForMissingStub: _i3.Future.value(), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), ) - as _i3.Future); + as _i4.Future); @override - _i3.Future read({ + _i4.Future read({ required String? key, - _i2.IOSOptions? iOptions, + _i2.AppleOptions? iOptions, _i2.AndroidOptions? aOptions, _i2.LinuxOptions? lOptions, _i2.WebOptions? webOptions, - _i2.MacOsOptions? mOptions, + _i2.AppleOptions? mOptions, _i2.WindowsOptions? wOptions, }) => (super.noSuchMethod( @@ -174,18 +217,18 @@ class MockFlutterSecureStorage extends _i1.Mock #mOptions: mOptions, #wOptions: wOptions, }), - returnValue: _i3.Future.value(), + returnValue: _i4.Future.value(), ) - as _i3.Future); + as _i4.Future); @override - _i3.Future containsKey({ + _i4.Future containsKey({ required String? key, - _i2.IOSOptions? iOptions, + _i2.AppleOptions? iOptions, _i2.AndroidOptions? aOptions, _i2.LinuxOptions? lOptions, _i2.WebOptions? webOptions, - _i2.MacOsOptions? mOptions, + _i2.AppleOptions? mOptions, _i2.WindowsOptions? wOptions, }) => (super.noSuchMethod( @@ -198,18 +241,18 @@ class MockFlutterSecureStorage extends _i1.Mock #mOptions: mOptions, #wOptions: wOptions, }), - returnValue: _i3.Future.value(false), + returnValue: _i4.Future.value(false), ) - as _i3.Future); + as _i4.Future); @override - _i3.Future delete({ + _i4.Future delete({ required String? key, - _i2.IOSOptions? iOptions, + _i2.AppleOptions? iOptions, _i2.AndroidOptions? aOptions, _i2.LinuxOptions? lOptions, _i2.WebOptions? webOptions, - _i2.MacOsOptions? mOptions, + _i2.AppleOptions? mOptions, _i2.WindowsOptions? wOptions, }) => (super.noSuchMethod( @@ -222,18 +265,18 @@ class MockFlutterSecureStorage extends _i1.Mock #mOptions: mOptions, #wOptions: wOptions, }), - returnValue: _i3.Future.value(), - returnValueForMissingStub: _i3.Future.value(), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), ) - as _i3.Future); + as _i4.Future); @override - _i3.Future> readAll({ - _i2.IOSOptions? iOptions, + _i4.Future> readAll({ + _i2.AppleOptions? iOptions, _i2.AndroidOptions? aOptions, _i2.LinuxOptions? lOptions, _i2.WebOptions? webOptions, - _i2.MacOsOptions? mOptions, + _i2.AppleOptions? mOptions, _i2.WindowsOptions? wOptions, }) => (super.noSuchMethod( @@ -245,19 +288,19 @@ class MockFlutterSecureStorage extends _i1.Mock #mOptions: mOptions, #wOptions: wOptions, }), - returnValue: _i3.Future>.value( + returnValue: _i4.Future>.value( {}, ), ) - as _i3.Future>); + as _i4.Future>); @override - _i3.Future deleteAll({ - _i2.IOSOptions? iOptions, + _i4.Future deleteAll({ + _i2.AppleOptions? iOptions, _i2.AndroidOptions? aOptions, _i2.LinuxOptions? lOptions, _i2.WebOptions? webOptions, - _i2.MacOsOptions? mOptions, + _i2.AppleOptions? mOptions, _i2.WindowsOptions? wOptions, }) => (super.noSuchMethod( @@ -269,8 +312,16 @@ class MockFlutterSecureStorage extends _i1.Mock #mOptions: mOptions, #wOptions: wOptions, }), - returnValue: _i3.Future.value(), - returnValueForMissingStub: _i3.Future.value(), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), + ) + as _i4.Future); + + @override + _i4.Future isCupertinoProtectedDataAvailable() => + (super.noSuchMethod( + Invocation.method(#isCupertinoProtectedDataAvailable, []), + returnValue: _i4.Future.value(), ) - as _i3.Future); + as _i4.Future); } diff --git a/test/models/exchange/incomplete_exchange_test.dart b/test/models/exchange/incomplete_exchange_test.dart new file mode 100644 index 0000000000..dd36e2b5ce --- /dev/null +++ b/test/models/exchange/incomplete_exchange_test.dart @@ -0,0 +1,71 @@ +import 'package:decimal/decimal.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/models/exchange/incomplete_exchange.dart'; +import 'package:stackwallet/models/exchange/response_objects/trade.dart'; +import 'package:stackwallet/models/isar/exchange_cache/currency.dart'; +import 'package:stackwallet/utilities/enums/exchange_rate_type_enum.dart'; + +class _Currency implements Currency { + @override + String get exchangeName => "exchange"; + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _Trade implements Trade { + _Trade(this.payInAmount); + + @override + final String payInAmount; + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +void main() { + test("pay-in amount follows the created trade", () { + final currency = _Currency(); + final model = IncompleteExchangeModel( + sendCurrency: currency, + receiveCurrency: currency, + rateInfo: "", + sendAmount: Decimal.parse("1.2"), + receiveAmount: Decimal.one, + rateType: ExchangeRateType.estimated, + reversed: false, + walletInitiated: false, + ); + final trade = _Trade("1.23456789"); + + expect(model.payInAmount, "1.2"); + model.trade = trade; + expect(model.payInAmount, "1.23456789"); + expect(model.payInDecimal, Decimal.parse("1.23456789")); + + model.trade = _Trade(""); + expect(model.payInDecimal, isNull); + model.trade = _Trade("not a number"); + expect(model.payInDecimal, isNull); + }); + + test("stores destination and refund memo values", () { + final currency = _Currency(); + final model = IncompleteExchangeModel( + sendCurrency: currency, + receiveCurrency: currency, + rateInfo: "", + sendAmount: Decimal.one, + receiveAmount: Decimal.one, + rateType: ExchangeRateType.estimated, + reversed: false, + walletInitiated: false, + ); + + model.extraId = "destination memo"; + model.refundExtraId = "refund memo"; + + expect(model.extraId, "destination memo"); + expect(model.refundExtraId, "refund memo"); + }); +} diff --git a/test/models/isar/mweb_pegout_test.dart b/test/models/isar/mweb_pegout_test.dart new file mode 100644 index 0000000000..0639019b1e --- /dev/null +++ b/test/models/isar/mweb_pegout_test.dart @@ -0,0 +1,88 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/models/isar/models/blockchain_data/utxo.dart'; +import 'package:stackwallet/wallets/crypto_currency/crypto_currency.dart'; +import 'package:stackwallet/wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dart'; + +void main() { + UTXO utxo({String? otherData}) => UTXO( + walletId: "walletId", + txid: "txid", + vout: 1, + value: 1000, + name: "", + isBlocked: false, + blockedReason: null, + isCoinbase: false, + blockHash: "blockHash", + blockHeight: 100, + blockTime: 1, + otherData: otherData, + ); + + group("MWEB pegout detection", () { + test("recognizes outputs after the HogAddr output", () { + final outputs = [ + { + "n": 0, + "scriptPubKey": {"type": "witness_mweb_hogaddr"}, + }, + { + "n": 1, + "scriptPubKey": {"type": "witness_v0_keyhash"}, + }, + ]; + + expect(isMwebPegoutOutput(outputs, 0), isFalse); + expect(isMwebPegoutOutput(outputs, 1), isTrue); + }); + + test("recognizes the HogAddr script when type is unavailable", () { + final outputs = [ + { + "n": 0, + "scriptPubKey": { + "hex": + "5820000000000000000000000000000000" + "0000000000000000000000000000000000", + }, + }, + ]; + + expect(isMwebPegoutOutput(outputs, 1), isTrue); + }); + + test("does not classify native MWEB or ordinary outputs as pegouts", () { + final outputs = [ + { + "n": 0, + "ismweb": true, + "scriptPubKey": {"type": "witness_v0_keyhash"}, + }, + ]; + + expect(isMwebPegoutOutput(outputs, 1), isFalse); + }); + }); + + test("Litecoin pegouts require six confirmations", () { + final maturity = Litecoin(CryptoCurrencyNetwork.main).mwebPegoutMaturity; + final pegout = utxo( + otherData: jsonEncode({UTXOOtherDataKeys.mwebPegoutMaturity: maturity}), + ); + + expect(pegout.isMwebPegout, isTrue); + expect(pegout.getConfirmations(104), 5); + expect(pegout.isConfirmed(104, 1, 1), isFalse); + expect(pegout.isConfirmed(104, 1, 1, overrideMinConfirms: 1), isFalse); + expect(pegout.isConfirmed(105, 1, 1), isTrue); + }); + + test("ordinary outputs retain the currency confirmation policy", () { + final ordinary = utxo(); + + expect(ordinary.isMwebPegout, isFalse); + expect(ordinary.isConfirmed(100, 1, 1), isTrue); + }); +} diff --git a/test/models/node_model_backup_test.dart b/test/models/node_model_backup_test.dart new file mode 100644 index 0000000000..d0259c1541 --- /dev/null +++ b/test/models/node_model_backup_test.dart @@ -0,0 +1,59 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/models/node_model.dart'; + +void main() { + test('restores current and legacy node backup fields', () { + final source = NodeModel( + host: 'node.example.com', + port: 50002, + name: 'Node', + id: 'current', + useSSL: false, + loginName: 'user', + enabled: false, + coinName: 'bitcoin', + isFailover: true, + isDown: false, + trusted: false, + torEnabled: false, + clearnetEnabled: false, + forceNoTor: true, + isPrimary: false, + nodeApiSecret: 'current-secret', + ); + expect(NodeModel.fromStackBackup(source.toMap()).toMap(), source.toMap()); + + final legacyMap = { + ...source.toMap(), + 'id': 'legacy', + 'useSSL': 'false', + 'enabled': 'false', + 'isFailover': 'true', + 'trusted': 'true', + 'torEnabled': 'false', + 'plainEnabled': 'false', + 'forceNoTor': 'true', + 'nodeApiSecret': 'legacy-secret', + }; + legacyMap.remove('clearEnabled'); + legacyMap.remove('isPrimary'); + final legacy = NodeModel.fromStackBackup( + legacyMap, + legacyPrimaryNodeIds: {'legacy'}, + ); + expect( + ( + legacy.useSSL, + legacy.enabled, + legacy.isFailover, + legacy.trusted, + legacy.torEnabled, + legacy.clearnetEnabled, + legacy.forceNoTor, + legacy.isPrimary, + legacy.nodeApiSecret, + ), + (false, false, true, true, false, false, true, true, 'legacy-secret'), + ); + }); +} diff --git a/test/pages/cakepay/cakepay_order_view_test.dart b/test/pages/cakepay/cakepay_order_view_test.dart new file mode 100644 index 0000000000..842bda17ce --- /dev/null +++ b/test/pages/cakepay/cakepay_order_view_test.dart @@ -0,0 +1,65 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/models/isar/stack_theme.dart'; +import 'package:stackwallet/pages/cakepay/cakepay_order_view.dart'; +import 'package:stackwallet/pages/wallet_view/transaction_views/transaction_details_view.dart'; +import 'package:stackwallet/providers/global/cakepay_orders_provider.dart'; +import 'package:stackwallet/providers/global/wallets_provider.dart'; +import 'package:stackwallet/services/cakepay/cakepay_orders_service.dart'; +import 'package:stackwallet/services/cakepay/src/models/order.dart'; +import 'package:stackwallet/services/wallets.dart'; +import 'package:stackwallet/themes/stack_colors.dart'; + +import '../../sample_data/theme_json.dart'; + +class _OrdersService extends CakePayOrdersService { + @override + void startPolling( + String orderId, { + Duration interval = CakePayOrdersService.defaultPollInterval, + }) {} +} + +void main() { + testWidgets("address copy button uses the visible address", (tester) async { + const address = "bc1qpaymentaddress"; + final order = CakePayOrder( + orderId: "order-id", + status: CakePayOrderStatus.new_, + paymentOptions: { + "BTC": CakePayPaymentOption( + ticker: "BTC", + amountFrom: 1, + address: address, + ), + }, + ); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + pCakePayOrdersService.overrideWithValue(_OrdersService()), + pWallets.overrideWithValue(Wallets.sharedInstance), + ], + child: MaterialApp( + theme: ThemeData( + extensions: [ + StackColors.fromStackColorTheme( + StackTheme.fromJson(json: lightThemeJsonMap), + ), + ], + ), + home: CakePayOrderView(order: order), + ), + ), + ); + + expect( + find.byWidgetPredicate( + (widget) => widget is IconCopyButton && widget.data == address, + ), + findsOneWidget, + ); + }); +} diff --git a/test/pages/exchange_view/exchange_form_debounce_test.dart b/test/pages/exchange_view/exchange_form_debounce_test.dart new file mode 100644 index 0000000000..7f118dc5b2 --- /dev/null +++ b/test/pages/exchange_view/exchange_form_debounce_test.dart @@ -0,0 +1,276 @@ +import 'package:decimal/decimal.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; +import 'package:stackwallet/models/exchange/aggregate_currency.dart'; +import 'package:stackwallet/models/isar/exchange_cache/currency.dart'; +import 'package:stackwallet/models/isar/exchange_cache/pair.dart'; +import 'package:stackwallet/models/isar/stack_theme.dart'; +import 'package:stackwallet/pages/exchange_view/exchange_form.dart'; +import 'package:stackwallet/providers/exchange/exchange_form_state_provider.dart'; +import 'package:stackwallet/providers/global/locale_provider.dart'; +import 'package:stackwallet/providers/global/prefs_provider.dart'; +import 'package:stackwallet/services/locale_service.dart'; +import 'package:stackwallet/themes/stack_colors.dart'; +import 'package:stackwallet/themes/theme_service.dart'; +import 'package:stackwallet/utilities/enums/exchange_rate_type_enum.dart'; +import 'package:stackwallet/utilities/prefs.dart'; +import 'package:tuple/tuple.dart'; + +import '../../sample_data/theme_json.dart'; + +class _MockThemeService extends Mock implements ThemeService {} + +class _MockPrefs extends Mock implements Prefs { + @override + bool get useTor => false; +} + +class _TestLocaleService extends LocaleService { + String _testLocale = "en_US"; + + @override + String get locale => _testLocale; + + void setLocale(String locale) { + _testLocale = locale; + notifyListeners(); + } +} + +void main() { + // A recognized legacy exchange name that ExchangeForm does not query. + const testExchangeName = "Majestic Bank"; + + AggregateCurrency currency(String ticker) { + return AggregateCurrency( + exchangeCurrencyPairs: [ + Tuple2( + testExchangeName, + Currency( + exchangeName: testExchangeName, + ticker: ticker, + name: ticker, + network: ticker.toLowerCase(), + image: "", + isFiat: false, + rateType: SupportedRateType.both, + isStackCoin: false, + tokenContract: null, + ), + ), + ], + ); + } + + Future pumpForm( + WidgetTester tester, { + AggregateCurrency? receive, + Decimal? sendAmount, + Decimal? receiveAmount, + bool fixedRate = false, + }) async { + await tester.binding.setSurfaceSize(const Size(1200, 1000)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + final stackTheme = StackTheme.fromJson(json: lightThemeJsonMap); + final themeService = _MockThemeService(); + when(themeService.getTheme(themeId: "light")).thenReturn(stackTheme); + final prefs = _MockPrefs(); + final localeService = _TestLocaleService(); + final container = ProviderContainer( + overrides: [ + pThemeService.overrideWithValue(themeService), + prefsChangeNotifierProvider.overrideWithValue(prefs), + localeServiceChangeNotifierProvider.overrideWithValue(localeService), + ], + ); + addTearDown(container.dispose); + if (fixedRate) { + container.read(efRateTypeProvider.notifier).state = + ExchangeRateType.fixed; + } + container.read(efSendAmountProvider.notifier).state = sendAmount; + container.read(efReceiveAmountProvider.notifier).state = receiveAmount; + final pair = container.read(efCurrencyPairProvider); + pair.setSend(currency("UNKNOWN")); + pair.setReceive(receive); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: MaterialApp( + theme: ThemeData( + extensions: [StackColors.fromStackColorTheme(stackTheme)], + ), + home: const Scaffold(body: ExchangeForm()), + ), + ), + ); + await tester.pump(); + return container; + } + + Future<(ProviderContainer, Finder, Finder)> enterBothAmountsWithinDebounce( + WidgetTester tester, + ) async { + final container = await pumpForm( + tester, + receive: currency("RECEIVE"), + sendAmount: Decimal.one, + receiveAmount: Decimal.fromInt(2), + fixedRate: true, + ); + final sendField = find.byType(TextField).first; + final receiveField = find.byType(TextField).last; + + await tester.tap(sendField); + await tester.pump(); + await tester.enterText(sendField, "3.12345678"); + + await tester.tap(receiveField); + await tester.pump(); + // The focus-driven provider refresh runs on the following frame and may + // rewrite the unfocused send controller from its still-stale provider. + await tester.pump(); + await tester.enterText(receiveField, "4.87654321"); + + return (container, sendField, receiveField); + } + + testWidgets("currency change cancels stale amount debounce", (tester) async { + final container = await pumpForm(tester); + final pair = container.read(efCurrencyPairProvider); + + final sendField = find.byType(TextField).first; + await tester.tap(sendField); + await tester.pump(); + await tester.enterText(sendField, "1.12345678"); + FocusManager.instance.primaryFocus?.unfocus(); + + pair.setSend(currency("LOW"), notifyListeners: true); + await tester.pump(); + + expect(container.read(efSendAmountProvider), Decimal.parse("1.12345678")); + expect(tester.widget(sendField).controller!.text, "1.12345678"); + + await tester.pump(const Duration(seconds: 2)); + + expect(container.read(efSendAmountProvider), Decimal.parse("1.12345678")); + expect(tester.widget(sendField).controller!.text, "1.12345678"); + }); + + testWidgets("swap commits amount before canceling its debounce", ( + tester, + ) async { + final container = await pumpForm(tester, receive: currency("LOW")); + + final sendField = find.byType(TextField).first; + await tester.tap(sendField); + await tester.pump(); + await tester.enterText(sendField, "1.12345678"); + + await tester.tap( + find.bySemanticsLabel("Swap Button. Reverse The Exchange Currencies."), + ); + await tester.pump(); + + expect( + container.read(efReceiveAmountProvider), + Decimal.parse("1.12345678"), + ); + + await tester.pump(const Duration(seconds: 2)); + + expect( + container.read(efReceiveAmountProvider), + Decimal.parse("1.12345678"), + ); + }); + + testWidgets("exchange input remains limited to eight fractional digits", ( + tester, + ) async { + final container = await pumpForm(tester); + final sendField = find.byType(TextField).first; + + await tester.tap(sendField); + await tester.pump(); + await tester.enterText(sendField, "1.12345678"); + await tester.enterText(sendField, "1.123456789"); + + expect(tester.widget(sendField).controller!.text, "1.12345678"); + + await tester.pump(const Duration(seconds: 2)); + + expect(container.read(efSendAmountProvider), Decimal.parse("1.12345678")); + }); + + testWidgets("swap preserves pending edits from both amount fields", ( + tester, + ) async { + final (container, _, _) = await enterBothAmountsWithinDebounce(tester); + + await tester.tap( + find.bySemanticsLabel("Swap Button. Reverse The Exchange Currencies."), + ); + await tester.pump(); + + expect(container.read(efSendAmountProvider), Decimal.parse("4.87654321")); + expect( + container.read(efReceiveAmountProvider), + Decimal.parse("3.12345678"), + ); + }); + + testWidgets("currency change preserves pending edits from both fields", ( + tester, + ) async { + final (container, _, _) = await enterBothAmountsWithinDebounce(tester); + + container + .read(efCurrencyPairProvider) + .setReceive(currency("NEXT"), notifyListeners: true); + await tester.pump(); + + expect(container.read(efSendAmountProvider), Decimal.parse("3.12345678")); + expect( + container.read(efReceiveAmountProvider), + Decimal.parse("4.87654321"), + ); + + await tester.pump(const Duration(seconds: 2)); + + expect(container.read(efSendAmountProvider), Decimal.parse("3.12345678")); + expect( + container.read(efReceiveAmountProvider), + Decimal.parse("4.87654321"), + ); + }); + + testWidgets("locale change relocalizes and flushes pending user text", ( + tester, + ) async { + final container = await pumpForm(tester); + final sendField = find.byType(TextField).first; + + await tester.tap(sendField); + await tester.pump(); + await tester.enterText(sendField, "3.12000001"); + + (container.read(localeServiceChangeNotifierProvider) as _TestLocaleService) + .setLocale("de_DE"); + await tester.pump(); + await tester.pump(); + + final amount = container.read(efSendAmountProvider); + expect(amount, Decimal.parse("3.12000001")); + expect(amount?.scale, 8); + expect(tester.widget(sendField).controller!.text, "3,12000001"); + + await tester.pump(const Duration(seconds: 2)); + + expect(container.read(efSendAmountProvider), amount); + }); +} diff --git a/test/pages/exchange_view/exchange_rate_sort_test.dart b/test/pages/exchange_view/exchange_rate_sort_test.dart new file mode 100644 index 0000000000..4fb94512a4 --- /dev/null +++ b/test/pages/exchange_view/exchange_rate_sort_test.dart @@ -0,0 +1,37 @@ +import 'package:decimal/decimal.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/models/exchange/response_objects/estimate.dart'; +import 'package:stackwallet/pages/exchange_view/sub_widgets/sorted_exchange_providers.dart'; +import 'package:stackwallet/services/exchange/exchange.dart'; + +void main() { + test('exchange rates sort highest first with failed providers last', () { + final exchange = Exchange.defaultExchange; + final dynamic state = SortedExchangeProviders( + exchangees: [exchange], + fixedRate: false, + reversed: false, + ).createState(); + + Estimate estimate(int rate) => Estimate( + estimatedAmount: Decimal.fromInt(rate), + fixedRate: false, + reversed: false, + exchangeProvider: exchange.name, + ); + + state.estimates.addAll(<(Exchange, List?)>[ + (exchange, [estimate(1)]), + (exchange, null), + (exchange, [estimate(3)]), + ]); + + final result = state.transform(Decimal.one, 'BTC') as List; + + expect(result.map((entry) => entry.$2?.estimatedAmount).toList(), [ + Decimal.fromInt(3), + Decimal.fromInt(1), + null, + ]); + }); +} diff --git a/test/pages/send_view/sol_token_amount_parsing_test.dart b/test/pages/send_view/sol_token_amount_parsing_test.dart new file mode 100644 index 0000000000..ab92fa9711 --- /dev/null +++ b/test/pages/send_view/sol_token_amount_parsing_test.dart @@ -0,0 +1,63 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/models/isar/models/solana/sol_contract.dart'; +import 'package:stackwallet/pages/send_view/sol_token_send_view.dart'; +import 'package:stackwallet/wallets/crypto_currency/crypto_currency.dart'; + +void main() { + final token = SolContract( + address: "mint", + name: "Token", + symbol: "TKN", + decimals: 6, + ); + final solana = Solana(CryptoCurrencyNetwork.main); + + test("mobile SPL inputs use the locale decimal separator", () { + expect( + parseMobileSolTokenAmount( + "1.25", + locale: "en_US", + coin: solana, + tokenContract: token, + )?.raw, + BigInt.from(1250000), + ); + expect( + parseMobileSolTokenAmount( + "1,25", + locale: "de_DE", + coin: solana, + tokenContract: token, + )?.raw, + BigInt.from(1250000), + ); + expect( + parseMobileSolTokenFiatAmount("1,25", locale: "de_DE")?.raw, + BigInt.from(125), + ); + }); + + test("mobile SPL inputs reject grouping and signs", () { + for (final value in ["1,000", "+1", "-1", " 1"]) { + expect( + parseMobileSolTokenAmount( + value, + locale: "en_US", + coin: solana, + tokenContract: token, + ), + isNull, + reason: value, + ); + } + expect( + parseMobileSolTokenAmount( + "1.000", + locale: "de_DE", + coin: solana, + tokenContract: token, + ), + isNull, + ); + }); +} diff --git a/test/pages/wallet_view/transaction_views/transaction_search_filter_view_test.dart b/test/pages/wallet_view/transaction_views/transaction_search_filter_view_test.dart new file mode 100644 index 0000000000..b4969a4c34 --- /dev/null +++ b/test/pages/wallet_view/transaction_views/transaction_search_filter_view_test.dart @@ -0,0 +1,67 @@ +import "package:flutter_test/flutter_test.dart"; +import "package:stackwallet/pages/wallet_view/transaction_views/transaction_search_filter_view.dart"; +import "package:stackwallet/utilities/amount/amount_formatter.dart"; +import "package:stackwallet/utilities/amount/amount_unit.dart"; +import "package:stackwallet/wallets/crypto_currency/crypto_currency.dart"; + +void main() { + AmountFormatter formatter(String locale) => AmountFormatter( + unit: AmountUnit.normal, + locale: locale, + coin: Bitcoin(CryptoCurrencyNetwork.main), + maxDecimals: 8, + ); + + test("transaction filter distinguishes empty from malformed amounts", () { + for (final text in ["", "."]) { + expect( + parseTransactionFilterAmountInput( + text: text, + locale: "en_US", + formatter: formatter("en_US"), + ), + (isValid: true, amount: null), + reason: text, + ); + } + + final trailingSeparator = parseTransactionFilterAmountInput( + text: "1.", + locale: "en_US", + formatter: formatter("en_US"), + ); + expect(trailingSeparator.isValid, isTrue); + expect(trailingSeparator.amount?.raw, BigInt.from(100000000)); + + for (final text in ["1..", "1.2."]) { + expect( + parseTransactionFilterAmountInput( + text: text, + locale: "en_US", + formatter: formatter("en_US"), + ).isValid, + isFalse, + reason: text, + ); + } + }); + + test("transaction filter uses the locale separator", () { + expect( + parseTransactionFilterAmountInput( + text: ",", + locale: "de_DE", + formatter: formatter("de_DE"), + ), + (isValid: true, amount: null), + ); + expect( + parseTransactionFilterAmountInput( + text: "1,,", + locale: "de_DE", + formatter: formatter("de_DE"), + ).isValid, + isFalse, + ); + }); +} diff --git a/test/pages_desktop_specific/desktop_exchange/desktop_trade_details_presenter_test.dart b/test/pages_desktop_specific/desktop_exchange/desktop_trade_details_presenter_test.dart new file mode 100644 index 0000000000..b81b04e7ec --- /dev/null +++ b/test/pages_desktop_specific/desktop_exchange/desktop_trade_details_presenter_test.dart @@ -0,0 +1,25 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/pages_desktop_specific/desktop_exchange/desktop_all_trades_view.dart'; + +void main() { + test("one transaction load opens one trade-details dialog", () async { + var loads = 0; + var presentations = 0; + String? presented; + + await loadAndPresentDesktopTradeDetails( + load: () async { + loads++; + return "transaction"; + }, + present: (value) { + presentations++; + presented = value; + }, + ); + + expect(loads, 1); + expect(presentations, 1); + expect(presented, "transaction"); + }); +} diff --git a/test/pages_desktop_specific/wallet/desktop_token_amount_parsing_test.dart b/test/pages_desktop_specific/wallet/desktop_token_amount_parsing_test.dart new file mode 100644 index 0000000000..6de3986481 --- /dev/null +++ b/test/pages_desktop_specific/wallet/desktop_token_amount_parsing_test.dart @@ -0,0 +1,56 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/models/isar/models/solana/sol_contract.dart'; +import 'package:stackwallet/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_sol_token_send.dart'; +import 'package:stackwallet/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_token_send.dart'; +import 'package:stackwallet/wallets/crypto_currency/crypto_currency.dart'; + +void main() { + final token = SolContract( + address: "mint", + name: "Token", + symbol: "TKN", + decimals: 6, + ); + final solana = Solana(CryptoCurrencyNetwork.main); + + test("desktop token inputs use the locale decimal separator", () { + expect( + parseDesktopSolTokenAmount( + "1.25", + locale: "en_US", + coin: solana, + tokenContract: token, + )?.raw, + BigInt.from(1250000), + ); + expect( + parseDesktopSolTokenAmount( + "1,25", + locale: "de_DE", + coin: solana, + tokenContract: token, + )?.raw, + BigInt.from(1250000), + ); + expect( + parseDesktopTokenFiatAmount("1,25", locale: "de_DE")?.raw, + BigInt.from(125), + ); + }); + + test("desktop token inputs reject grouping and signs", () { + for (final value in ["1,000", "+1", "-1", " 1"]) { + expect( + parseDesktopSolTokenAmount( + value, + locale: "en_US", + coin: solana, + tokenContract: token, + ), + isNull, + reason: value, + ); + } + expect(parseDesktopSolTokenFiatAmount("1.000", locale: "de_DE"), isNull); + }); +} diff --git a/test/paynym_p2tr_test.dart b/test/paynym_p2tr_test.dart index e4fd1abb0d..73ba39dece 100644 --- a/test/paynym_p2tr_test.dart +++ b/test/paynym_p2tr_test.dart @@ -2,12 +2,33 @@ import 'package:bip32/bip32.dart' as bip32; import 'package:bip39/bip39.dart' as bip39; import 'package:bip47/bip47.dart'; import 'package:bitcoindart/bitcoindart.dart' as bitcoindart; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/exceptions/wallet/paynym_send_exception.dart'; +import 'package:stackwallet/models/input.dart'; +import 'package:stackwallet/models/isar/models/blockchain_data/utxo.dart'; import 'package:stackwallet/models/paynym/paynym_account_lite.dart'; -import 'package:test/test.dart'; +import 'package:stackwallet/utilities/enums/derive_path_type_enum.dart'; +import 'package:stackwallet/wallets/wallet/wallet_mixin_interfaces/paynym_interface.dart'; + +UTXO _utxo(String txid, int blockTime, String address) => UTXO( + walletId: 'wallet', + txid: txid, + vout: 0, + value: 1, + name: '', + isBlocked: false, + blockedReason: null, + isCoinbase: false, + blockHash: 'hash', + blockHeight: 1, + blockTime: blockTime, + address: address, +); void main() { const mnemonic = - 'response seminar brave million suit skate inhale proud weapon daring champion'; + 'response seminar brave million suit skate inhale proud weapon ' + 'daring champion'; final networkType = bip32.NetworkType( wif: bitcoindart.bitcoin.wif, @@ -43,6 +64,48 @@ void main() { taprootPaymentCodeString = taprootCode.toString(); }); + test('notification UTXOs prefer non-Taproot then oldest', () { + final utxos = [ + _utxo('taproot-newer', 50, 'bc1ptaproot'), + _utxo('legacy-newer', 200, 'bc1qlegacy'), + _utxo('taproot-older', 25, 'tb1ptaproot'), + _utxo('legacy-older', 100, '1legacy'), + ]..sort(comparePaynymNotificationUtxos); + + expect(utxos.map((utxo) => utxo.txid).toList(), [ + 'legacy-older', + 'legacy-newer', + 'taproot-older', + 'taproot-newer', + ]); + }); + + test('notification requires a non-Taproot designated input', () { + final segwit = StandardInput( + _utxo('segwit', 1, 'bc1qsegwit'), + derivePathType: DerivePathType.bip84, + ); + final taproot = StandardInput( + _utxo('taproot', 1, 'bc1ptaproot'), + derivePathType: DerivePathType.bip86, + ); + + expect( + () => validatePaynymNotificationInputs([taproot]), + throwsA( + isA().having( + (error) => error.message, + 'message', + contains('non-Taproot UTXO'), + ), + ), + ); + expect( + () => validatePaynymNotificationInputs([segwit, taproot]), + returnsNormally, + ); + }); + group('PaynymAccountLite taproot inference', () { test('inferTaproot returns true for taproot-enabled payment code', () { final result = PaynymAccountLite.inferTaproot(taprootPaymentCodeString); diff --git a/test/price_test.dart b/test/price_test.dart index 468295b79b..d28988fecd 100644 --- a/test/price_test.dart +++ b/test/price_test.dart @@ -31,6 +31,10 @@ void main() { prices, contains("Instance of 'Bitcoin': (change24h: 0.0, value: 1)"), ); + expect( + prices, + contains("Instance of 'BitcoinFrost': (change24h: 0.0, value: 1)"), + ); expect( prices, contains( diff --git a/test/screen_tests/onboarding/create_pin_view_screen_test.dart b/test/screen_tests/onboarding/create_pin_view_screen_test.dart index 455d347c45..9029e84792 100644 --- a/test/screen_tests/onboarding/create_pin_view_screen_test.dart +++ b/test/screen_tests/onboarding/create_pin_view_screen_test.dart @@ -1,3 +1,5 @@ +import 'dart:io'; + import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -10,7 +12,6 @@ import 'package:stackwallet/providers/global/prefs_provider.dart'; import 'package:stackwallet/themes/stack_colors.dart'; import 'package:stackwallet/themes/theme_service.dart'; import 'package:stackwallet/utilities/biometrics.dart'; -import 'package:stackwallet/utilities/prefs.dart'; import 'package:stackwallet/widgets/custom_pin_put/pin_keyboard.dart'; import '../../sample_data/theme_json.dart'; @@ -132,7 +133,7 @@ void main() { expect(await platformOverrides.secureStorage.read(key: kPinKey), '1234'); expect(platformOverrides.secureStorage.writes, 1); - expect(biometrics.calls, 0); + expect(biometrics.calls, Platform.isLinux ? 0 : 1); verify(prefs.useBiometrics = false).called(1); verify(prefs.hasPin = true).called(1); diff --git a/test/services/coins/bitcoin/bitcoin_wallet_test.mocks.dart b/test/services/coins/bitcoin/bitcoin_wallet_test.mocks.dart index b9d265e2f8..7c9e989ed8 100644 --- a/test/services/coins/bitcoin/bitcoin_wallet_test.mocks.dart +++ b/test/services/coins/bitcoin/bitcoin_wallet_test.mocks.dart @@ -186,11 +186,16 @@ class MockElectrumXClient extends _i1.Mock implements _i5.ElectrumXClient { as _i8.Future>); @override - _i8.Future ping({String? requestID, int? retryCount = 1}) => + _i8.Future ping({ + String? requestID, + int? retryCount = 1, + Duration? timeout = const Duration(seconds: 30), + }) => (super.noSuchMethod( Invocation.method(#ping, [], { #requestID: requestID, #retryCount: retryCount, + #timeout: timeout, }), returnValue: _i8.Future.value(false), ) diff --git a/test/services/coins/bitcoincash/bitcoincash_wallet_test.mocks.dart b/test/services/coins/bitcoincash/bitcoincash_wallet_test.mocks.dart index a91186a54f..99b488ffb8 100644 --- a/test/services/coins/bitcoincash/bitcoincash_wallet_test.mocks.dart +++ b/test/services/coins/bitcoincash/bitcoincash_wallet_test.mocks.dart @@ -186,11 +186,16 @@ class MockElectrumXClient extends _i1.Mock implements _i5.ElectrumXClient { as _i8.Future>); @override - _i8.Future ping({String? requestID, int? retryCount = 1}) => + _i8.Future ping({ + String? requestID, + int? retryCount = 1, + Duration? timeout = const Duration(seconds: 30), + }) => (super.noSuchMethod( Invocation.method(#ping, [], { #requestID: requestID, #retryCount: retryCount, + #timeout: timeout, }), returnValue: _i8.Future.value(false), ) diff --git a/test/services/coins/dogecoin/dogecoin_wallet_test.mocks.dart b/test/services/coins/dogecoin/dogecoin_wallet_test.mocks.dart index 8fde902450..144bc982ef 100644 --- a/test/services/coins/dogecoin/dogecoin_wallet_test.mocks.dart +++ b/test/services/coins/dogecoin/dogecoin_wallet_test.mocks.dart @@ -186,11 +186,16 @@ class MockElectrumXClient extends _i1.Mock implements _i5.ElectrumXClient { as _i8.Future>); @override - _i8.Future ping({String? requestID, int? retryCount = 1}) => + _i8.Future ping({ + String? requestID, + int? retryCount = 1, + Duration? timeout = const Duration(seconds: 30), + }) => (super.noSuchMethod( Invocation.method(#ping, [], { #requestID: requestID, #retryCount: retryCount, + #timeout: timeout, }), returnValue: _i8.Future.value(false), ) diff --git a/test/services/coins/namecoin/namecoin_wallet_test.mocks.dart b/test/services/coins/namecoin/namecoin_wallet_test.mocks.dart index 9ecb591912..5cf930564d 100644 --- a/test/services/coins/namecoin/namecoin_wallet_test.mocks.dart +++ b/test/services/coins/namecoin/namecoin_wallet_test.mocks.dart @@ -186,11 +186,16 @@ class MockElectrumXClient extends _i1.Mock implements _i5.ElectrumXClient { as _i8.Future>); @override - _i8.Future ping({String? requestID, int? retryCount = 1}) => + _i8.Future ping({ + String? requestID, + int? retryCount = 1, + Duration? timeout = const Duration(seconds: 30), + }) => (super.noSuchMethod( Invocation.method(#ping, [], { #requestID: requestID, #retryCount: retryCount, + #timeout: timeout, }), returnValue: _i8.Future.value(false), ) diff --git a/test/services/coins/particl/particl_wallet_test.mocks.dart b/test/services/coins/particl/particl_wallet_test.mocks.dart index 6929d60a42..ea579e379d 100644 --- a/test/services/coins/particl/particl_wallet_test.mocks.dart +++ b/test/services/coins/particl/particl_wallet_test.mocks.dart @@ -186,11 +186,16 @@ class MockElectrumXClient extends _i1.Mock implements _i5.ElectrumXClient { as _i8.Future>); @override - _i8.Future ping({String? requestID, int? retryCount = 1}) => + _i8.Future ping({ + String? requestID, + int? retryCount = 1, + Duration? timeout = const Duration(seconds: 30), + }) => (super.noSuchMethod( Invocation.method(#ping, [], { #requestID: requestID, #retryCount: retryCount, + #timeout: timeout, }), returnValue: _i8.Future.value(false), ) diff --git a/test/services/exchange/cyphergoat/cyphergoat_exchange_test.dart b/test/services/exchange/cyphergoat/cyphergoat_exchange_test.dart new file mode 100644 index 0000000000..2ea7c1d714 --- /dev/null +++ b/test/services/exchange/cyphergoat/cyphergoat_exchange_test.dart @@ -0,0 +1,42 @@ +import 'package:decimal/decimal.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/models/exchange/response_objects/estimate.dart'; +import 'package:stackwallet/services/exchange/cyphergoat/cyphergoat_exchange.dart'; + +void main() { + test("does not advertise extra ID support", () { + expect(CypherGoatExchange.instance.supportsExtraId, isFalse); + }); + + for (final values in [ + (destination: "12345", refund: ""), + (destination: null, refund: "refund memo"), + ]) { + test("rejects an unsupported " + "${values.destination == null ? "refund" : "destination"} memo " + "before a network call", () async { + final response = await CypherGoatExchange.instance.createTrade( + from: "btc", + to: "xrp", + fromNetwork: "btc", + toNetwork: "xrp", + fixedRate: false, + amount: Decimal.one, + addressTo: "destination", + extraId: values.destination, + addressRefund: "", + refundExtraId: values.refund, + estimate: Estimate( + estimatedAmount: Decimal.one, + fixedRate: false, + reversed: false, + exchangeProvider: "provider", + ), + reversed: false, + ); + + expect(response.value, isNull); + expect(response.exception.toString(), contains("does not support")); + }); + } +} diff --git a/test/services/exchange/nanswap_exchange_test.dart b/test/services/exchange/nanswap_exchange_test.dart new file mode 100644 index 0000000000..ee0bcd5b96 --- /dev/null +++ b/test/services/exchange/nanswap_exchange_test.dart @@ -0,0 +1,37 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/services/exchange/exchange_response.dart'; +import 'package:stackwallet/services/exchange/nanswap/api_response_models/n_trade.dart'; +import 'package:stackwallet/services/exchange/nanswap/nanswap_exchange.dart'; + +void main() { + test('maps Nanswap source and destination networks', () async { + final nTrade = NTrade( + id: 'trade-id', + from: 'BTC', + to: 'XNO', + expectedAmountFrom: 1, + expectedAmountTo: 2, + payinAddress: 'pay-in', + payoutAddress: 'pay-out', + ); + final exchange = NanswapExchange.forTesting( + getOrder: ({required String id}) async { + expect(id, nTrade.id); + return ExchangeResponse(value: nTrade); + }, + ); + + final trade = (await exchange.getTrade(nTrade.id)).value!; + final staleTrade = trade.copyWith( + payInNetwork: 'XNO', + payOutNetwork: 'BTC', + ); + final updatedTrade = (await exchange.updateTrade(staleTrade)).value!; + + expect((trade.payInNetwork, trade.payOutNetwork), ('BTC', 'XNO')); + expect( + (updatedTrade.payInNetwork, updatedTrade.payOutNetwork), + ('BTC', 'XNO'), + ); + }); +} diff --git a/test/utilities/amount/amount_field_relocalization_test.dart b/test/utilities/amount/amount_field_relocalization_test.dart new file mode 100644 index 0000000000..06d2bc6782 --- /dev/null +++ b/test/utilities/amount/amount_field_relocalization_test.dart @@ -0,0 +1,32 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/utilities/amount/amount_field_relocalization.dart'; + +void main() { + test('relocalizing an amount preserves its selection', () { + const selection = TextSelection( + baseOffset: 4, + extentOffset: 1, + affinity: TextAffinity.upstream, + isDirectional: true, + ); + final controller = TextEditingController.fromValue( + const TextEditingValue( + text: '12.34', + selection: selection, + composing: TextRange(start: 2, end: 4), + ), + ); + addTearDown(controller.dispose); + + relocalizeAmountController( + controller, + sourceLocale: 'en_US', + targetLocale: 'de_DE', + ); + + expect(controller.text, '12,34'); + expect(controller.selection, selection); + expect(controller.value.composing, TextRange.empty); + }); +} diff --git a/test/utilities/amount/amount_unit_test.dart b/test/utilities/amount/amount_unit_test.dart index 96a708702a..52a6ccb63e 100644 --- a/test/utilities/amount/amount_unit_test.dart +++ b/test/utilities/amount/amount_unit_test.dart @@ -1,10 +1,21 @@ import 'package:decimal/decimal.dart'; +import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/models/isar/models/solana/sol_contract.dart'; import 'package:stackwallet/utilities/amount/amount.dart'; +import 'package:stackwallet/utilities/amount/amount_formatter.dart'; +import 'package:stackwallet/utilities/amount/amount_input_formatter.dart'; import 'package:stackwallet/utilities/amount/amount_unit.dart'; +import 'package:stackwallet/utilities/util.dart'; import 'package:stackwallet/wallets/crypto_currency/crypto_currency.dart'; void main() { + TextEditingController testController() { + final controller = TextEditingController(); + addTearDown(controller.dispose); + return controller; + } + test("displayAmount BTC", () { final Amount amount = Amount( rawValue: BigInt.from(1012345678), @@ -154,75 +165,681 @@ void main() { ); }); - test("parse eth string to amount", () { + test("tryParse rejects display-formatted strings", () { final eth = Ethereum(CryptoCurrencyNetwork.main); - final Amount amount = Amount.fromDecimal( - Decimal.parse("10.123456789123456789"), - fractionDigits: eth.fractionDigits, - ); + final btc = Bitcoin(CryptoCurrencyNetwork.main); + // Display output (grouped, unit-suffixed, "~"-prefixed) is never + // valid input; only editable text parses. expect( AmountUnit.nano.tryParse( "~10,123,456,789.1 gwei", locale: "en_US", coin: eth, ), - Amount.fromDecimal( - Decimal.parse("10.1234567891"), - fractionDigits: eth.fractionDigits, + isNull, + ); + expect( + AmountUnit.normal.tryParse("10.12345678 BTC", locale: "en_US", coin: btc), + isNull, + ); + expect( + AmountUnit.milli.tryParse( + "10,123.45678 mBTC", + locale: "en_US", + coin: btc, ), + isNull, ); expect( - AmountUnit.atto.tryParse( - "10,123,456,789,123,456,789 wei", - locale: "en_US", - coin: eth, + AmountUnit.normal + .tryParse("10.12345678", locale: "en_US", coin: btc) + ?.raw, + BigInt.from(1012345678), + ); + expect( + AmountUnit.milli.tryParse("10123.45678", locale: "en_US", coin: btc)?.raw, + BigInt.from(1012345678), + ); + expect( + AmountUnit.nano.tryParse("1012345678", locale: "en_US", coin: btc)?.raw, + BigInt.from(1012345678), + ); + }); + + test("amount field parsing rejects signs and ASCII whitespace", () { + final coin = Bitcoin(CryptoCurrencyNetwork.main); + final formatter = AmountFormatter( + unit: AmountUnit.normal, + locale: "en_US", + coin: coin, + maxDecimals: 8, + ); + + expect(formatter.tryParseEditable("5")?.decimal, Decimal.fromInt(5)); + + for (final value in [ + "+5", + "-5", + "1,000", + for (final codePoint in [0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x20]) + "1${String.fromCharCode(codePoint)}234", + ]) { + expect(formatter.tryParseEditable(value), isNull, reason: value); + expect( + Amount.tryParseFiatString(value, locale: "en_US"), + isNull, + reason: value, + ); + } + + expect(formatter.tryParseEditable("0.000000001"), isNull); + expect(Amount.tryParseFiatString("1.001", locale: "en_US"), isNull); + + expect( + AmountUnit.normal.tryParse("5 legacy", locale: "en_US", coin: coin), + isNull, + ); + }); + + test("strict parsing accepts only the locale decimal separator", () { + final coin = Bitcoin(CryptoCurrencyNetwork.main); + + // de_DE uses "," as its decimal separator, so ASCII dots are rejected. + for (final value in ["1.5", "1.234", "1.000", "10.000", "1.000,5"]) { + expect( + AmountUnit.normal.tryParse(value, locale: "de_DE", coin: coin), + isNull, + reason: value, + ); + } + expect(Amount.tryParseFiatString("1.50", locale: "de_DE"), isNull); + + // The locale's own decimal separator parses. + expect( + AmountUnit.normal.tryParse("1,5", locale: "de_DE", coin: coin)?.decimal, + Decimal.parse("1.5"), + ); + expect( + Amount.tryParseFiatString("1,50", locale: "de_DE")?.decimal, + Decimal.parse("1.5"), + ); + expect( + Amount.tryParseEditableDecimal("1,234", locale: "de_DE"), + Decimal.parse("1.234"), + ); + + expect( + Util.getSymbolsFor(locale: "de-Latn-CH")?.DECIMAL_SEP, + Util.getSymbolsFor(locale: "de_CH")?.DECIMAL_SEP, + ); + }); + + test("formatter rejects non-decimal separators", () { + TextEditingValue edit( + AmountInputFormatter formatter, + String oldText, + String newText, + ) { + return formatter.formatEditUpdate( + TextEditingValue( + text: oldText, + selection: TextSelection.collapsed(offset: oldText.length), + ), + TextEditingValue( + text: newText, + selection: TextSelection.collapsed(offset: newText.length), + ), + ); + } + + final de = AmountInputFormatter( + controller: testController(), + decimals: 8, + locale: "de_DE", + ); + expect(edit(de, "1", "1.").text, "1"); + expect(edit(de, "1", "1,").text, "1,"); + expect(edit(de, "", "1.200").text, ""); + expect(edit(de, "", "1.200,5").text, ""); + + final us = AmountInputFormatter( + controller: testController(), + decimals: 8, + locale: "en_US", + ); + expect(edit(us, "1", "1,").text, "1"); + expect(edit(us, "1", "1.").text, "1."); + expect(edit(us, "", "1,200").text, ""); + expect(edit(us, "", "1,200.5").text, ""); + }); + + test("canonical and token parsing preserve exact precision", () { + final canonical = Amount.tryParseCanonicalAmount( + "1.234567", + fractionDigits: 6, + ); + expect(canonical?.raw, BigInt.from(1234567)); + // Excess trailing zeros still represent the exact value; only real + // sub-atomic precision is rejected. + expect( + Amount.tryParseCanonicalAmount("1.2345670", fractionDigits: 6)?.raw, + BigInt.from(1234567), + ); + expect( + Amount.tryParseCanonicalAmount( + "0.0000000100000000", + fractionDigits: 8, + )?.raw, + BigInt.one, + ); + expect( + Amount.tryParseCanonicalAmount("1.2345671", fractionDigits: 6), + isNull, + ); + expect(Amount.tryParseCanonicalAmount("1", fractionDigits: -1), isNull); + + // Externally supplied QR/URI amounts may opt into truncation instead of + // rejection. + expect( + Amount.tryParseCanonicalAmount( + "0.123456789", + fractionDigits: 8, + truncateOverprecision: true, + )?.raw, + BigInt.from(12345678), + ); + expect( + Amount.tryParseCanonicalAmount( + "1.2345671", + fractionDigits: 6, + truncateOverprecision: true, + )?.raw, + BigInt.from(1234567), + ); + // Truncation never loosens the grammar itself. + expect( + Amount.tryParseCanonicalAmount( + "1e-3", + fractionDigits: 8, + truncateOverprecision: true, + ), + isNull, + ); + expect( + Amount.tryParseCanonicalAmount( + "-1", + fractionDigits: 8, + truncateOverprecision: true, ), - amount, + isNull, + ); + + final token = SolContract( + address: "mint", + name: "Token", + symbol: "TKN", + decimals: 6, ); + final parsedToken = AmountUnit.normal.tryParse( + "1000", + locale: "en_US", + coin: Solana(CryptoCurrencyNetwork.main), + tokenContract: token, + ); + expect(parsedToken?.raw, BigInt.from(1000000000)); + expect(parsedToken?.fractionDigits, 6); }); - test("parse btc string to amount", () { - final Amount amount = Amount( - rawValue: BigInt.from(1012345678), - fractionDigits: 8, + test("formatter tolerates an invalid selection", () { + final formatter = AmountInputFormatter( + controller: testController(), + decimals: 8, + locale: "en_US", + ); + final result = formatter.formatEditUpdate( + TextEditingValue.empty, + const TextEditingValue(text: "1234"), ); + expect(result.text, "1234"); + const composingValue = TextEditingValue( + text: "1.", + selection: TextSelection.collapsed(offset: 2), + composing: TextRange(start: 1, end: 2), + ); + final commaFormatter = AmountInputFormatter( + controller: testController(), + decimals: 8, + locale: "de_DE", + ); expect( - AmountUnit.normal.tryParse( - "10.12345678 BTC", - locale: "en_US", - coin: Bitcoin(CryptoCurrencyNetwork.main), + commaFormatter.formatEditUpdate(TextEditingValue.empty, composingValue), + composingValue, + ); + + // When the IME commits that invalid composing text, the formatter + // sanitizes instead of trapping the field in an unparseable state. + final committed = commaFormatter.formatEditUpdate( + composingValue, + const TextEditingValue( + text: "1.", + selection: TextSelection.collapsed(offset: 2), ), - amount, ); + expect(committed.text, "1"); + expect(committed.composing, TextRange.empty); + }); + + test("formatter restores valid text after rebuilding during IME input", () { + const valid = TextEditingValue( + text: "1,5", + selection: TextSelection.collapsed(offset: 3), + ); + const composing = TextEditingValue( + text: "1.5", + selection: TextSelection.collapsed(offset: 3), + composing: TextRange(start: 1, end: 2), + ); + const committed = TextEditingValue( + text: "1.5", + selection: TextSelection.collapsed(offset: 3), + ); + + final controller = testController(); + final beforeRebuild = AmountInputFormatter( + controller: controller, + decimals: 8, + locale: "de_DE", + ); + expect(beforeRebuild.formatEditUpdate(valid, composing), composing); + + final afterRebuild = AmountInputFormatter( + controller: controller, + decimals: 8, + locale: "de_DE", + ); + expect(afterRebuild.formatEditUpdate(composing, committed), valid); + }); + + test("formatter restores valid text when an IME changes text at commit", () { + const valid = TextEditingValue( + text: "1,5", + selection: TextSelection.collapsed(offset: 3), + ); + const composing = TextEditingValue( + text: "1.5", + selection: TextSelection.collapsed(offset: 3), + composing: TextRange(start: 1, end: 2), + ); + const changedCommit = TextEditingValue( + text: "1.", + selection: TextSelection.collapsed(offset: 2), + ); + + final controller = testController(); + AmountInputFormatter( + controller: controller, + decimals: 8, + locale: "de_DE", + ).formatEditUpdate(valid, composing); expect( - AmountUnit.milli.tryParse( - "10,123.45678 mBTC", + AmountInputFormatter( + controller: controller, + decimals: 8, + locale: "de_DE", + ).formatEditUpdate(composing, changedCommit), + valid, + ); + }); + + test("formatter recovery is isolated by controller and configuration", () { + TextEditingValue value(String text) => TextEditingValue( + text: text, + selection: TextSelection.collapsed(offset: text.length), + ); + + final controller = testController(); + final de = AmountInputFormatter( + controller: controller, + decimals: 8, + locale: "de_DE", + ); + de.formatEditUpdate(TextEditingValue.empty, value("1,5")); + + final otherController = AmountInputFormatter( + controller: testController(), + decimals: 8, + locale: "de_DE", + ); + expect( + otherController.formatEditUpdate(value("2.5"), value("2.5")).text, + "2", + ); + + final otherLocale = AmountInputFormatter( + controller: controller, + decimals: 8, + locale: "en_US", + ); + expect(otherLocale.formatEditUpdate(value("2,5"), value("2,5")).text, "2"); + + final highPrecision = AmountInputFormatter( + controller: controller, + decimals: 8, + locale: "en_US", + ); + highPrecision.formatEditUpdate(TextEditingValue.empty, value("1.234")); + final lowPrecision = AmountInputFormatter( + controller: controller, + decimals: 2, + locale: "en_US", + ); + expect( + lowPrecision.formatEditUpdate(value("1.234"), value("1.234")).text, + "1.23", + ); + }); + + test( + "formatter does not restore stale recovery after a config round trip", + () { + TextEditingValue value(String text) => TextEditingValue( + text: text, + selection: TextSelection.collapsed(offset: text.length), + ); + + final controller = testController(); + final fiat = AmountInputFormatter( + controller: controller, + decimals: 2, locale: "en_US", - coin: Bitcoin(CryptoCurrencyNetwork.main), - ), - amount, + ); + fiat.formatEditUpdate(TextEditingValue.empty, value("50")); + + final highPrecision = AmountInputFormatter( + controller: controller, + decimals: 8, + locale: "en_US", + ); + highPrecision.formatEditUpdate(value("50"), value("0.12345")); + + final fiatAgain = AmountInputFormatter( + controller: controller, + decimals: 2, + locale: "en_US", + ); + expect( + fiatAgain.formatEditUpdate(value("0.12345"), value("0.123456")).text, + "0.12", + ); + }, + ); + + test("formatter clears recovery and isolates amount-unit shifts", () { + TextEditingValue value(String text) => TextEditingValue( + text: text, + selection: TextSelection.collapsed(offset: text.length), + ); + + final controller = testController(); + final de = AmountInputFormatter( + controller: controller, + decimals: 8, + locale: "de_DE", + ); + de.formatEditUpdate(TextEditingValue.empty, value("1,5")); + de.formatEditUpdate(value("1,5"), TextEditingValue.empty); + expect( + AmountInputFormatter( + controller: controller, + decimals: 8, + locale: "de_DE", + ).formatEditUpdate(value("2.5"), value("2.5")).text, + "2", ); + final normal = AmountInputFormatter( + controller: controller, + decimals: 8, + locale: "en_US", + unit: AmountUnit.normal, + ); + normal.formatEditUpdate(TextEditingValue.empty, value("1.123456")); expect( - AmountUnit.micro.tryParse( - "10,123,456.7822 µBTC", + AmountInputFormatter( + controller: controller, + decimals: 8, locale: "en_US", - coin: Bitcoin(CryptoCurrencyNetwork.main), + unit: AmountUnit.milli, + ).formatEditUpdate(value("1.123456"), value("1.123456")).text, + "1.12345", + ); + }); + + test("formatter never joins digits around stripped invalid characters", () { + TextEditingValue committedInvalid(String text, {int? caret}) { + return TextEditingValue( + text: text, + selection: TextSelection.collapsed(offset: caret ?? text.length), + ); + } + + // Both old and new values invalid (IME commit path): the recovered text + // must be a prefix of the committed text, never digits joined across a + // stripped character ("1.5" must not become "15"). + final de = AmountInputFormatter( + controller: testController(), + decimals: 8, + locale: "de_DE", + ); + expect( + de + .formatEditUpdate(committedInvalid("1.5"), committedInvalid("1.5")) + .text, + "1", + ); + expect( + de + .formatEditUpdate(committedInvalid("1e3"), committedInvalid("1e3")) + .text, + "1", + ); + expect( + de.formatEditUpdate(committedInvalid("-5"), committedInvalid("-5")).text, + "", + ); + expect( + de + .formatEditUpdate( + committedInvalid("abc12", caret: 3), + committedInvalid("abc12", caret: 3), + ) + .text, + "", + ); + + // A formatter that admitted an invalid composing edit restores the valid + // value from immediately before that composition. + final usController = testController(); + final us = AmountInputFormatter( + controller: usController, + decimals: 8, + locale: "en_US", + ); + final valid = committedInvalid("1.5"); + const composingInvalid = TextEditingValue( + text: "1,5", + selection: TextSelection.collapsed(offset: 3), + composing: TextRange(start: 1, end: 2), + ); + expect(us.formatEditUpdate(valid, composingInvalid), composingInvalid); + expect( + AmountInputFormatter( + controller: usController, + decimals: 8, + locale: "en_US", + ).formatEditUpdate(composingInvalid, committedInvalid("1,5")).text, + "1.5", + ); + }); + + test("editable parsers accept one trailing decimal separator", () { + expect(Amount.tryParseEditableDecimal("1.", locale: "en_US"), Decimal.one); + expect(Amount.tryParseEditableDecimal("1,", locale: "de_DE"), Decimal.one); + expect( + AmountUnit.normal + .tryParse( + "10.", + locale: "en_US", + coin: Bitcoin(CryptoCurrencyNetwork.main), + ) + ?.raw, + BigInt.from(1000000000), + ); + // Separator-only and doubled separators stay invalid. + for (final (value, locale) in [ + (".", "en_US"), + (",", "de_DE"), + ("1..", "en_US"), + (".5.", "en_US"), + ]) { + expect( + Amount.tryParseEditableDecimal(value, locale: locale), + isNull, + reason: "$locale '$value'", + ); + } + // Canonical parsing stays strict. + expect(Amount.tryParseCanonicalAmount("1.", fractionDigits: 8), isNull); + }); + + test("formatEditableDecimal writes locale-editable text", () { + expect( + Amount.formatEditableDecimal(Decimal.parse("1.5"), locale: "en_US"), + "1.5", + ); + expect( + Amount.formatEditableDecimal(Decimal.parse("1.5"), locale: "de_DE"), + "1,5", + ); + // no grouping, ever: editable text must parse back via tryParseEditable* + expect( + Amount.formatEditableDecimal( + Decimal.parse("1234567.89"), + locale: "de_DE", + ), + "1234567,89", + ); + expect( + Amount.tryParseEditableDecimal( + Amount.formatEditableDecimal( + Decimal.parse("1234567.89"), + locale: "de_DE", + ), + locale: "de_DE", ), - amount, + Decimal.parse("1234567.89"), ); + }); + test("formatFixedDecimal writes locale-editable fixed text", () { expect( - AmountUnit.nano.tryParse( - "1,012,345,678 sats", + Amount.formatFixedDecimal( + Decimal.parse("1.5"), + fractionDigits: 3, + locale: "de_DE", + ), + "1,500", + ); + expect( + Amount.formatFixedDecimal( + Decimal.parse("1.5"), + fractionDigits: 2, locale: "en_US", - coin: Bitcoin(CryptoCurrencyNetwork.main), ), - amount, + "1.50", + ); + expect( + () => Amount.formatFixedDecimal( + Decimal.one, + fractionDigits: -1, + locale: "en_US", + ), + throwsArgumentError, + ); + }); + + test("formatEditable round-trips through tryParseEditable", () { + final coin = Bitcoin(CryptoCurrencyNetwork.main); + final amount = Amount(rawValue: BigInt.from(1012345678), fractionDigits: 8); + + for (final locale in ["en_US", "de_DE"]) { + for (final unit in [ + AmountUnit.normal, + AmountUnit.milli, + AmountUnit.nano, + ]) { + final formatter = AmountFormatter( + unit: unit, + locale: locale, + coin: coin, + maxDecimals: 8, + ); + final text = formatter.formatEditable(amount); + expect( + formatter.tryParseEditable(text), + amount, + reason: "$locale $unit $text", + ); + } + } + + expect( + AmountUnit.normal.formatEditable(amount: amount, locale: "de_DE"), + "10,12345678", + ); + expect( + AmountUnit.nano.formatEditable(amount: amount, locale: "de_DE"), + "1012345678", + ); + }); + + test("relocalizeEditableDecimal rewrites the decimal separator", () { + expect( + Amount.relocalizeEditableDecimal( + "1,5", + sourceLocale: "de_DE", + targetLocale: "en_US", + ), + "1.5", + ); + expect( + Amount.relocalizeEditableDecimal( + "1.5", + sourceLocale: "en_US", + targetLocale: "de_DE", + ), + "1,5", + ); + expect( + Amount.relocalizeEditableDecimal( + "", + sourceLocale: "en_US", + targetLocale: "de_DE", + ), + "", + ); + // same separator locales: unchanged + expect( + Amount.relocalizeEditableDecimal( + "1.5", + sourceLocale: "en_US", + targetLocale: "en_GB", + ), + "1.5", ); }); } diff --git a/test/utilities/desktop_password_service_test.dart b/test/utilities/desktop_password_service_test.dart new file mode 100644 index 0000000000..0947f2c353 --- /dev/null +++ b/test/utilities/desktop_password_service_test.dart @@ -0,0 +1,229 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:hive_ce/hive.dart' show Box; +import 'package:stack_wallet_backup/secure_storage.dart'; +import 'package:stackwallet/db/hive/db.dart'; +import 'package:stackwallet/utilities/desktop_password_service.dart'; + +const _blobKey = "swbKeyBlobKeyStringID"; +const _versionKey = "swbKeyBlobVersionKeyStringID"; + +void main() { + late Directory tempDirectory; + + setUp(() async { + await DB.instance.hive.close(); + tempDirectory = await Directory.systemTemp.createTemp("dps_test_"); + DB.instance.hive.init(tempDirectory.path); + }); + + tearDown(() async { + await DB.instance.hive.close(); + await tempDirectory.delete(recursive: true); + }); + + test("new password persists in the legacy-compatible format", () async { + const passphrase = "correct horse battery staple"; + final service = DPS(); + await service.initFromNew(passphrase); + + final stored = await _readStoredCredentials(); + expect(stored.keys, {_blobKey, _versionKey}); + expect(stored.version, kLatestBlobVersion.toString()); + await StorageCryptoHandler.fromExisting( + passphrase, + stored.blob!, + int.parse(stored.version!), + ); + + final restarted = DPS(); + await restarted.initFromExisting(passphrase); + expect(await restarted.verifyPassphrase(passphrase), isTrue); + }); + + test("failed setup does not install an in-memory handler", () async { + final service = DPS(); + final initialization = service.initFromNew("new password"); + final blockingBox = await _openIncompatibleBox(); + try { + await expectLater(initialization, throwsA(anything)); + expect(() => service.handler, throwsException); + } finally { + await blockingBox.close(); + } + + await service.initFromNew("new password"); + expect(await service.verifyPassphrase("new password"), isTrue); + }); + + test("password change is atomic from the service's perspective", () async { + const field = "wallet secret"; + const plaintext = "seed material"; + final service = DPS(); + await service.initFromNew("old password"); + final ciphertext = await service.handler.encryptValue(field, plaintext); + final originalBlob = (await _readStoredCredentials()).blob!; + expect(await _desktopDataFileContains(tempDirectory, originalBlob), isTrue); + + final failedChange = service.changePassphrase( + "old password", + "failed password", + ); + final blockingBox = await _openIncompatibleBox(); + try { + expect(await failedChange, isFalse); + } finally { + await blockingBox.close(); + } + + expect((await _readStoredCredentials()).blob, originalBlob); + expect(await service.verifyPassphrase("old password"), isTrue); + + final compactionBlocker = Directory( + _desktopDataPath(tempDirectory, "hivec"), + ); + await compactionBlocker.create(); + try { + expect( + await service.changePassphrase("old password", "new password"), + isTrue, + ); + } finally { + await compactionBlocker.delete(); + } + final stored = await _readStoredCredentials(); + expect(stored.blob, isNot(originalBlob)); + expect(stored.version, kLatestBlobVersion.toString()); + expect(await _desktopDataFileContains(tempDirectory, originalBlob), isTrue); + + final restarted = DPS(); + expect(await restarted.verifyPassphrase("old password"), isFalse); + await restarted.initFromExisting("new password"); + expect(await restarted.handler.decryptValue(field, ciphertext), plaintext); + expect( + await _desktopDataFileContains(tempDirectory, originalBlob), + isFalse, + ); + }); + + test("failed automatic upgrade stays usable and retries", () async { + const passphrase = "legacy password"; + const field = "wallet secret"; + const plaintext = "seed material"; + final oldHandler = await StorageCryptoHandler.fromNewPassphrase( + passphrase, + 1, + ); + final oldBlob = await oldHandler.getKeyBlob(); + final ciphertext = await oldHandler.encryptValue(field, plaintext); + await _writeStoredCredentials(blob: oldBlob, version: 1); + + final firstLogin = DPS(); + final initialization = firstLogin.initFromExisting(passphrase); + final blockingBox = await _openIncompatibleBox(); + try { + await initialization; + expect( + await firstLogin.handler.decryptValue(field, ciphertext), + plaintext, + ); + } finally { + await blockingBox.close(); + } + + var stored = await _readStoredCredentials(); + expect(stored.blob, oldBlob); + expect(stored.version, "1"); + + final retriedLogin = DPS(); + await retriedLogin.initFromExisting(passphrase); + stored = await _readStoredCredentials(); + expect(stored.blob, isNot(oldBlob)); + expect(stored.version, kLatestBlobVersion.toString()); + expect( + await retriedLogin.handler.decryptValue(field, ciphertext), + plaintext, + ); + expect(await _desktopDataFileContains(tempDirectory, oldBlob), isFalse); + + final restarted = DPS(); + await restarted.initFromExisting(passphrase); + expect(await restarted.handler.decryptValue(field, ciphertext), plaintext); + }); + + test("interrupted upgrade states recover and finish at latest", () async { + const passphrase = "legacy password"; + + final latestHandler = await StorageCryptoHandler.fromNewPassphrase( + passphrase, + kLatestBlobVersion, + ); + final latestBlob = await latestHandler.getKeyBlob(); + await _writeStoredCredentials(blob: latestBlob); + await DPS().initFromExisting(passphrase); + var stored = await _readStoredCredentials(); + expect(stored.blob, latestBlob); + expect(stored.version, kLatestBlobVersion.toString()); + + await DB.instance.hive.deleteBoxFromDisk(kBoxNameDesktopData); + final oldHandler = await StorageCryptoHandler.fromNewPassphrase( + passphrase, + 1, + ); + final oldBlob = await oldHandler.getKeyBlob(); + await _writeStoredCredentials(blob: oldBlob, version: kLatestBlobVersion); + await DPS().initFromExisting(passphrase); + stored = await _readStoredCredentials(); + expect(stored.blob, isNot(oldBlob)); + expect(stored.version, kLatestBlobVersion.toString()); + }); +} + +Future<({String? blob, String? version, Set keys})> +_readStoredCredentials() async { + final box = await DB.instance.hive.openBox(kBoxNameDesktopData); + final result = ( + blob: box.get(_blobKey), + version: box.get(_versionKey), + keys: box.keys.toSet(), + ); + await box.close(); + return result; +} + +Future _writeStoredCredentials({ + required String blob, + int? version, +}) async { + final box = await DB.instance.hive.openBox(kBoxNameDesktopData); + await box.put(_blobKey, blob); + if (version != null) { + await box.put(_versionKey, version.toString()); + } + await box.close(); +} + +Future> _openIncompatibleBox() async { + final deadline = DateTime.now().add(const Duration(seconds: 5)); + while (true) { + try { + return await DB.instance.hive.openBox(kBoxNameDesktopData); + } catch (_) { + if (DateTime.now().isAfter(deadline)) { + rethrow; + } + await Future.delayed(const Duration(milliseconds: 10)); + } + } +} + +String _desktopDataPath(Directory directory, String extension) => + "${directory.path}${Platform.pathSeparator}" + "${kBoxNameDesktopData.toLowerCase()}.$extension"; + +Future _desktopDataFileContains(Directory directory, String value) async { + final bytes = await File(_desktopDataPath(directory, "hive")).readAsBytes(); + return latin1.decode(bytes).contains(value); +} diff --git a/test/utilities/extra_id_currency_support_test.dart b/test/utilities/extra_id_currency_support_test.dart new file mode 100644 index 0000000000..4fd296f07f --- /dev/null +++ b/test/utilities/extra_id_currency_support_test.dart @@ -0,0 +1,26 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/utilities/extra_id_currency_support.dart'; + +void main() { + test("known tag currencies match case-insensitively", () { + for (final ticker in [ + "xrp", + "XRP", + " xlm ", + "Atom", + "eos", + "hbar", + "ton", + ]) { + expect(ExtraIdCurrencySupport.mayRequire(ticker), isTrue, reason: ticker); + } + + for (final ticker in ["btc", "eth", "xmr", "ltc", "doge", "bnb", ""]) { + expect( + ExtraIdCurrencySupport.mayRequire(ticker), + isFalse, + reason: ticker, + ); + } + }); +} diff --git a/test/utilities/fee_rate_type_enum_test.dart b/test/utilities/fee_rate_type_enum_test.dart new file mode 100644 index 0000000000..424736a353 --- /dev/null +++ b/test/utilities/fee_rate_type_enum_test.dart @@ -0,0 +1,20 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/utilities/enums/fee_rate_type_enum.dart'; + +void main() { + group("FeeRateTypeExt.customSatsPerVByte", () { + test("returns the selected rate for a custom fee", () { + expect(FeeRateType.custom.customSatsPerVByte(7), 7); + }); + + test("returns null for preset fees", () { + for (final feeRateType in [ + FeeRateType.fast, + FeeRateType.average, + FeeRateType.slow, + ]) { + expect(feeRateType.customSatsPerVByte(7), isNull); + } + }); + }); +} diff --git a/test/utilities/integer_input_test.dart b/test/utilities/integer_input_test.dart new file mode 100644 index 0000000000..c7238e6eaf --- /dev/null +++ b/test/utilities/integer_input_test.dart @@ -0,0 +1,54 @@ +import "package:flutter_test/flutter_test.dart"; +import "package:stackwallet/utilities/integer_input.dart"; + +void main() { + test("integer input rejects malformed text without normalizing it", () { + for (final value in ["1.5", "1,5", "1e3", "1 000", "0x5208", "+21000"]) { + expect(tryParseIntegerInput(value), isNull, reason: value); + } + + expect(tryParseIntegerInput(" 1 "), 1); + }); + + test("integer input preserves signed decimal support", () { + expect(tryParseIntegerInput("-42"), -42); + expect(tryParseIntegerInput(" -42 "), -42); + expect(tryParseIntegerInput("-42", minimum: 0), isNull); + }); + + test("integer input enforces inclusive bounds", () { + expect(tryParseIntegerInput("0", minimum: 0), 0); + expect(tryParseIntegerInput("-1", minimum: 0), isNull); + expect( + tryParseIntegerInput("21000", minimum: 21000, maximum: 30000000), + 21000, + ); + expect( + tryParseIntegerInput("30000000", minimum: 21000, maximum: 30000000), + 30000000, + ); + expect( + tryParseIntegerInput("30000001", minimum: 21000, maximum: 30000000), + isNull, + ); + }); + + test("optional integer input distinguishes blank from malformed", () { + expect(parseOptionalIntegerInput("", minimum: 0), ( + isValid: true, + value: null, + )); + expect(parseOptionalIntegerInput("0", minimum: 0), ( + isValid: true, + value: 0, + )); + expect(parseOptionalIntegerInput("1.5", minimum: 0), ( + isValid: false, + value: null, + )); + expect(parseOptionalIntegerInput("-1", minimum: 0), ( + isValid: false, + value: null, + )); + }); +} diff --git a/test/utilities/node_uri_util_test.dart b/test/utilities/node_uri_util_test.dart index 42d8474a0c..2f112dcea7 100644 --- a/test/utilities/node_uri_util_test.dart +++ b/test/utilities/node_uri_util_test.dart @@ -13,19 +13,22 @@ void main() { test("Valid wowrpc scheme node uri", () { expect( - NodeQrUtil.decodeUri( - "wowrpc://nodo:password@10.0.0.10:18083", - ), + NodeQrUtil.decodeUri("wowrpc://nodo:password@10.0.0.10:18083"), isA(), ); }); + test("Node uri requires an explicit port", () { + expect(() => NodeQrUtil.decodeUri("xmrrpc://bob.onion:0"), throwsException); + expect(() => NodeQrUtil.decodeUri("xmrrpc://bob.onion"), throwsException); + expect(() => NodeQrUtil.decodeUri("wowrpc://bob.onion"), throwsException); + expect(NodeQrUtil.decodeUri("xmrrpc://bob.onion:18083").port, 18083); + }); + test("Invalid authority node uri", () { String? message; try { - NodeQrUtil.decodeUri( - "nodo:password@bob.onion:18083?label=Nodo Tor Node", - ); + NodeQrUtil.decodeUri("nodo:password@bob.onion:18083?label=Nodo Tor Node"); } catch (e) { message = e.toString(); } @@ -77,18 +80,14 @@ void main() { test("encoding to string", () { const validString = "xmrrpc://nodo:password@bob.onion:18083?label=Nodo+Tor+Node"; - final data = NodeQrUtil.decodeUri( - validString, - ); + final data = NodeQrUtil.decodeUri(validString); expect(data.encode(), validString); }); test("normal to string", () { const validString = "xmrrpc://nodo:password@bob.onion:18083?label=Nodo+Tor+Node"; - final data = NodeQrUtil.decodeUri( - validString, - ); + final data = NodeQrUtil.decodeUri(validString); expect( data.toString(), "MoneroNodeQrData {" @@ -101,4 +100,14 @@ void main() { "}", ); }); + + test("node port validation", () { + expect(isValidNodePort(null), false); + expect(isValidNodePort(0), false); + expect(isValidNodePort(-1), false); + expect(isValidNodePort(65536), false); + expect(isValidNodePort(1), true); + expect(isValidNodePort(18081), true); + expect(isValidNodePort(65535), true); + }); } diff --git a/test/wallets/dash_policy_test.dart b/test/wallets/dash_policy_test.dart new file mode 100644 index 0000000000..0e6a238a3e --- /dev/null +++ b/test/wallets/dash_policy_test.dart @@ -0,0 +1,11 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/wallets/crypto_currency/crypto_currency.dart'; + +void main() { + test('Dash dust limit uses network policy units', () { + final dash = Dash(CryptoCurrencyNetwork.main); + + expect(dash.dustLimit.raw, BigInt.from(546)); + expect(dash.dustLimit.fractionDigits, 8); + }); +} diff --git a/test/wallets/electrum_fee_planner_test.dart b/test/wallets/electrum_fee_planner_test.dart new file mode 100644 index 0000000000..fc47ed63dc --- /dev/null +++ b/test/wallets/electrum_fee_planner_test.dart @@ -0,0 +1,201 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/wallets/wallet/wallet_mixin_interfaces/electrum_fee_planner.dart'; + +typedef _Payment = ({BigInt recipientAmount, BigInt? changeAmount}); + +Future<({ElectrumFeeResult<_Payment> result, List<_Payment> builds})> _plan({ + required ElectrumFeeMode mode, + required int inputTotal, + required int recipientAmount, + required int dustLimit, + required List vSizes, + int? satsPerVByte = 1, + int feeRatePerKB = 1000, + int? minimumFeeAmount, +}) async { + final builds = <_Payment>[]; + var buildIndex = 0; + final result = await planElectrumFee<_Payment>( + mode: mode, + inputTotal: BigInt.from(inputTotal), + recipientAmount: BigInt.from(recipientAmount), + dustLimit: BigInt.from(dustLimit), + satsPerVByte: satsPerVByte, + feeRatePerKB: BigInt.from(feeRatePerKB), + minimumFeeAmount: minimumFeeAmount == null + ? null + : BigInt.from(minimumFeeAmount), + build: ({required recipientAmount, changeAmount}) async { + final payment = ( + recipientAmount: recipientAmount, + changeAmount: changeAmount, + ); + builds.add(payment); + final vSize = + vSizes[buildIndex < vSizes.length ? buildIndex++ : vSizes.length - 1]; + return (transaction: payment, vSize: vSize); + }, + ); + return (result: result, builds: builds); +} + +void main() { + test('keeps the larger fee when measured vsize shrinks', () async { + final plan = await _plan( + mode: ElectrumFeeMode.sweep, + inputTotal: 10000, + recipientAmount: 10000, + dustLimit: 546, + vSizes: [192, 191], + ); + + expect(plan.result.fee, BigInt.from(192)); + expect(plan.result.transaction.recipientAmount, BigInt.from(9808)); + expect(plan.builds.length, 2); + }); + + test('rounds per-kilobyte fees up', () async { + final plan = await _plan( + mode: ElectrumFeeMode.sweep, + inputTotal: 10000, + recipientAmount: 10000, + dustLimit: 546, + vSizes: [191], + satsPerVByte: null, + feeRatePerKB: 1001, + ); + + expect(plan.result.fee, BigInt.from(192)); + }); + + test('custom sats/vByte overrides the per-kilobyte rate', () async { + final plan = await _plan( + mode: ElectrumFeeMode.sweep, + inputTotal: 10000, + recipientAmount: 10000, + dustLimit: 546, + vSizes: [191], + satsPerVByte: 2, + feeRatePerKB: 50000, + ); + + expect(plan.result.fee, BigInt.from(382)); + }); + + test('does not let a minimum fee underpay the measured vsize', () async { + final plan = await _plan( + mode: ElectrumFeeMode.sweep, + inputTotal: 10000, + recipientAmount: 10000, + dustLimit: 546, + vSizes: [225], + satsPerVByte: null, + feeRatePerKB: 0, + minimumFeeAmount: 100, + ); + + expect(plan.result.fee, BigInt.from(225)); + }); + + test('keeps exact-dust fixed change after vsize shrinks', () async { + final plan = await _plan( + mode: ElectrumFeeMode.fixedAmount, + inputTotal: 1319, + recipientAmount: 547, + dustLimit: 546, + vSizes: [226, 225], + ); + + expect(plan.result.fee, BigInt.from(226)); + expect(plan.result.transaction.recipientAmount, BigInt.from(547)); + expect(plan.result.transaction.changeAmount, BigInt.from(546)); + }); + + test('accepts an exact-dust fixed recipient', () async { + final plan = await _plan( + mode: ElectrumFeeMode.fixedAmount, + inputTotal: 772, + recipientAmount: 546, + dustLimit: 546, + vSizes: [226], + ); + + expect(plan.result.fee, BigInt.from(226)); + expect(plan.result.transaction.recipientAmount, BigInt.from(546)); + }); + + test('subtracts the fee and preserves change', () async { + final plan = await _plan( + mode: ElectrumFeeMode.subtractFeeFromAmount, + inputTotal: 10000, + recipientAmount: 6000, + dustLimit: 546, + vSizes: [225], + ); + + expect(plan.result.fee, BigInt.from(225)); + expect(plan.result.transaction.recipientAmount, BigInt.from(5775)); + expect(plan.result.transaction.changeAmount, BigInt.from(4000)); + }); + + test('uses a sub-dust surplus toward the subtracted fee', () async { + final plan = await _plan( + mode: ElectrumFeeMode.subtractFeeFromAmount, + inputTotal: 10000, + recipientAmount: 9900, + dustLimit: 546, + vSizes: [225], + ); + + expect(plan.result.fee, BigInt.from(225)); + expect(plan.result.transaction.recipientAmount, BigInt.from(9775)); + expect(plan.result.transaction.changeAmount, isNull); + }); + + test('uses an equal sub-dust surplus as the fee', () async { + final plan = await _plan( + mode: ElectrumFeeMode.subtractFeeFromAmount, + inputTotal: 10000, + recipientAmount: 9775, + dustLimit: 546, + vSizes: [225], + ); + + expect(plan.result.fee, BigInt.from(225)); + expect(plan.result.transaction.recipientAmount, BigInt.from(9775)); + expect(plan.result.transaction.changeAmount, isNull); + }); + + test('returns excess sub-dust surplus to the recipient', () async { + final plan = await _plan( + mode: ElectrumFeeMode.subtractFeeFromAmount, + inputTotal: 10000, + recipientAmount: 9700, + dustLimit: 546, + vSizes: [225], + ); + + expect(plan.result.fee, BigInt.from(225)); + expect(plan.result.transaction.recipientAmount, BigInt.from(9775)); + expect(plan.result.transaction.changeAmount, isNull); + }); + + test('fixed mode requests another input when the fee is short', () { + expect( + _plan( + mode: ElectrumFeeMode.fixedAmount, + inputTotal: 10000, + recipientAmount: 9900, + dustLimit: 546, + vSizes: [225], + ), + throwsA( + isA().having( + (e) => e.requiredFee, + 'requiredFee', + BigInt.from(225), + ), + ), + ); + }); +} diff --git a/test/wallets/epiccash_routing_test.dart b/test/wallets/epiccash_routing_test.dart new file mode 100644 index 0000000000..2b56d64641 --- /dev/null +++ b/test/wallets/epiccash_routing_test.dart @@ -0,0 +1,13 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/wallets/crypto_currency/crypto_currency.dart'; +import 'package:stackwallet/wallets/wallet/impl/epiccash_wallet.dart'; + +void main() { + test('HTTP receivers bypass Epicbox', () { + final wallet = EpiccashWallet(CryptoCurrencyNetwork.main); + + expect(wallet.shouldCheckEpicbox('http://receiver'), isFalse); + expect(wallet.shouldCheckEpicbox('https://receiver'), isFalse); + expect(wallet.shouldCheckEpicbox('user@epicbox.example'), isTrue); + }); +} diff --git a/test/wallets/ethereum_fee_caps_test.dart b/test/wallets/ethereum_fee_caps_test.dart new file mode 100644 index 0000000000..2ba8a69d07 --- /dev/null +++ b/test/wallets/ethereum_fee_caps_test.dart @@ -0,0 +1,42 @@ +import "package:flutter_test/flutter_test.dart"; +import "package:stackwallet/wallets/wallet/impl/ethereum_wallet.dart"; + +void main() { + test("preset max fee includes priority after base-fee headroom", () { + final caps = resolveEip1559FeeCaps( + baseFee: BigInt.from(10), + priorityFeePerGas: BigInt.two, + ); + + expect(caps.maxFeePerGas, BigInt.from(22)); + expect(caps.maxPriorityFeePerGas, BigInt.two); + }); + + test("custom max fee remains the total EIP-1559 cap", () { + final caps = resolveEip1559FeeCaps( + baseFee: BigInt.from(10), + priorityFeePerGas: BigInt.two, + customMaxFeePerGas: BigInt.from(15), + ); + + expect(caps.maxFeePerGas, BigInt.from(15)); + expect(caps.maxPriorityFeePerGas, BigInt.two); + }); + + test("rejects a priority cap above the total max fee", () { + expect( + () => resolveEip1559FeeCaps( + baseFee: BigInt.from(10), + priorityFeePerGas: BigInt.from(11), + customMaxFeePerGas: BigInt.from(10), + ), + throwsA( + isA().having( + (error) => error.toString(), + "message", + contains("Max priority fee per gas exceeds max fee per gas"), + ), + ), + ); + }); +} diff --git a/test/wallets/ethereum_replaced_transaction_test.dart b/test/wallets/ethereum_replaced_transaction_test.dart new file mode 100644 index 0000000000..f2029bc7ab --- /dev/null +++ b/test/wallets/ethereum_replaced_transaction_test.dart @@ -0,0 +1,230 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/models/isar/models/blockchain_data/transaction.dart'; +import 'package:stackwallet/models/isar/models/blockchain_data/v2/transaction_v2.dart'; +import 'package:stackwallet/wallets/wallet/impl/ethereum_wallet.dart'; +import 'package:web3dart/web3dart.dart' as web3; + +const _walletId = "wallet"; +const _otherWalletId = "otherWallet"; +const _address = "0x1111111111111111111111111111111111111111"; +const _nativeTxid = + "0x1111111111111111111111111111111111111111111111111111111111111111"; +const _tokenTxid = + "0x2222222222222222222222222222222222222222222222222222222222222222"; +const _minedTxid = + "0x3333333333333333333333333333333333333333333333333333333333333333"; + +void main() { + test("finds replaced pending ETH and token transactions", () async { + final nativeTransaction = _transaction( + id: 1, + txid: _nativeTxid, + nonce: 6, + subType: TransactionSubType.none, + ); + final tokenTransaction = _transaction( + id: 2, + txid: _tokenTxid, + nonce: 7, + subType: TransactionSubType.ethToken, + type: TransactionType.sentToSelf, + ); + final transactions = { + _nativeTxid: null, + _tokenTxid: _transactionInformation(_tokenTxid, mined: false), + }; + final lookedUpTxids = []; + int transactionCountCalls = 0; + + final replacedTransactions = await findReplacedPendingEthereumTransactions( + walletId: _walletId, + transactions: [nativeTransaction, tokenTransaction], + getLatestConfirmedNonce: () async { + transactionCountCalls++; + return 8; + }, + getTransactionByHash: (txid) async { + lookedUpTxids.add(txid); + return transactions[txid]; + }, + ); + + expect( + replacedTransactions.map((transaction) => transaction.id), + unorderedEquals([nativeTransaction.id, tokenTransaction.id]), + ); + expect(transactionCountCalls, 1); + expect(lookedUpTxids, unorderedEquals([_nativeTxid, _tokenTxid])); + }); + + test("preserves transactions without replacement proof", () async { + final unconsumed = _transaction(id: 1, txid: "unconsumed", nonce: 9); + final mined = _transaction(id: 2, txid: _minedTxid, nonce: 8); + final incoming = _transaction( + id: 3, + txid: "incoming", + nonce: 7, + type: TransactionType.incoming, + ); + final unsupportedSubtype = _transaction( + id: 4, + txid: "unsupportedSubtype", + nonce: 6, + subType: TransactionSubType.cashFusion, + ); + final confirmed = _transaction( + id: 5, + txid: "confirmed", + nonce: 5, + height: 1, + ); + final otherWallet = _transaction( + id: 6, + txid: "otherWallet", + nonce: 4, + walletId: _otherWalletId, + ); + final missingNonce = _transaction(id: 7, txid: "missingNonce", nonce: null); + final lookedUpTxids = []; + + final replacedTransactions = await findReplacedPendingEthereumTransactions( + walletId: _walletId, + transactions: [ + unconsumed, + mined, + incoming, + unsupportedSubtype, + confirmed, + otherWallet, + missingNonce, + ], + getLatestConfirmedNonce: () async => 9, + getTransactionByHash: (txid) async { + lookedUpTxids.add(txid); + return _transactionInformation(txid); + }, + ); + + expect(replacedTransactions, isEmpty); + expect(lookedUpTxids, [_minedTxid]); + }); + + test("preserves pending transactions when nonce lookup fails", () async { + int transactionLookupCalls = 0; + Object? lookupError; + + final replacedTransactions = await findReplacedPendingEthereumTransactions( + walletId: _walletId, + transactions: [_transaction(id: 1, txid: _nativeTxid, nonce: 1)], + getLatestConfirmedNonce: () async => + throw StateError("nonce lookup failed"), + getTransactionByHash: (txid) async { + transactionLookupCalls++; + return null; + }, + onNonceLookupError: (error, stackTrace) { + lookupError = error; + }, + ); + + expect(replacedTransactions, isEmpty); + expect(transactionLookupCalls, 0); + expect(lookupError, isA()); + }); + + test("preserves pending transactions when hash lookup fails", () async { + Object? lookupError; + TransactionV2? failedTransaction; + + final transaction = _transaction(id: 1, txid: _nativeTxid, nonce: 1); + final replacedTransactions = await findReplacedPendingEthereumTransactions( + walletId: _walletId, + transactions: [transaction], + getLatestConfirmedNonce: () async => 2, + getTransactionByHash: (txid) async => + throw StateError("hash lookup failed"), + onTransactionLookupError: (transaction, error, stackTrace) { + failedTransaction = transaction; + lookupError = error; + }, + ); + + expect(replacedTransactions, isEmpty); + expect(failedTransaction, same(transaction)); + expect(lookupError, isA()); + }); + + test("does not query the node without pending transactions", () async { + int transactionCountCalls = 0; + int transactionLookupCalls = 0; + + final replacedTransactions = await findReplacedPendingEthereumTransactions( + walletId: _walletId, + transactions: [ + _transaction( + id: 1, + txid: "incoming", + nonce: 1, + type: TransactionType.incoming, + ), + ], + getLatestConfirmedNonce: () async { + transactionCountCalls++; + return 2; + }, + getTransactionByHash: (txid) async { + transactionLookupCalls++; + return null; + }, + ); + + expect(replacedTransactions, isEmpty); + expect(transactionCountCalls, 0); + expect(transactionLookupCalls, 0); + }); +} + +TransactionV2 _transaction({ + required int id, + required String txid, + required int? nonce, + String walletId = _walletId, + int? height, + TransactionType type = TransactionType.outgoing, + TransactionSubType subType = TransactionSubType.none, +}) => TransactionV2( + walletId: walletId, + blockHash: height == null ? null : "blockHash", + hash: txid, + txid: txid, + timestamp: 1, + height: height, + inputs: const [], + outputs: const [], + version: -1, + type: type, + subType: subType, + otherData: jsonEncode({TxV2OdKeys.nonce: nonce}), +)..id = id; + +web3.TransactionInformation _transactionInformation( + String txid, { + bool mined = true, +}) => web3.TransactionInformation.fromMap({ + "blockHash": mined ? "blockHash" : null, + "blockNumber": mined ? "1" : null, + "from": _address, + "gas": "21000", + "gasPrice": "1", + "hash": txid, + "input": "0x", + "nonce": "1", + "to": _address, + "transactionIndex": mined ? "0" : null, + "value": "1", + "v": "27", + "r": "0x1", + "s": "0x1", +}); diff --git a/test/wallets/firo_address_validation_test.dart b/test/wallets/firo_address_validation_test.dart new file mode 100644 index 0000000000..74e668c258 --- /dev/null +++ b/test/wallets/firo_address_validation_test.dart @@ -0,0 +1,57 @@ +import "package:coinlib_flutter/coinlib_flutter.dart" as coinlib; +import "package:flutter_test/flutter_test.dart"; +import "package:stackwallet/models/isar/models/blockchain_data/address.dart"; +import "package:stackwallet/wallets/crypto_currency/crypto_currency.dart"; + +void main() { + final mainnet = Firo(CryptoCurrencyNetwork.main); + final testnet = Firo(CryptoCurrencyNetwork.test); + + test("accepts Firo transparent addresses", () { + expect( + mainnet.validateAddress("a8VV7vMzJdTQj1eLEJNskhLEBUxfNWhpAg"), + isTrue, + ); + expect( + mainnet.getAddressType("a8VV7vMzJdTQj1eLEJNskhLEBUxfNWhpAg"), + AddressType.p2pkh, + ); + expect( + testnet.validateAddress("THqfkegzJjpF4PQFAWPhJWMWagwHecfqva"), + isTrue, + ); + expect( + testnet.getAddressType("THqfkegzJjpF4PQFAWPhJWMWagwHecfqva"), + AddressType.p2pkh, + ); + }); + + test("rejects Bitcoin Bech32 addresses", () { + const mainnetBitcoin = "bc1qc5ymmsay89r6gr4fy2kklvrkuvzyln4shdvjhf"; + const testnetBitcoin = "tb1qzzlm6mnc8k54mx6akehl8p9ray8r439va5ndyq"; + + expect(mainnet.validateAddress(mainnetBitcoin), isFalse); + expect(mainnet.getAddressType(mainnetBitcoin), isNull); + expect( + () => coinlib.Address.fromString(mainnetBitcoin, mainnet.networkParams), + throwsA(anything), + ); + expect(testnet.validateAddress(testnetBitcoin), isFalse); + expect(testnet.getAddressType(testnetBitcoin), isNull); + expect( + () => coinlib.Address.fromString(testnetBitcoin, testnet.networkParams), + throwsA(anything), + ); + }); + + test("keeps Firo exchange addresses", () { + expect( + mainnet.validateAddress("EXXMGtieRLNGfgewJ4jJCN4kZFTUcjYMDdHs"), + isTrue, + ); + expect( + testnet.validateAddress("EXTKtrsZSTGU2vUbuCV6sBDVqPAS3JQkaYJ3"), + isTrue, + ); + }); +} diff --git a/test/wallets/nano_interface_test.dart b/test/wallets/nano_interface_test.dart new file mode 100644 index 0000000000..ca57ba5cdf --- /dev/null +++ b/test/wallets/nano_interface_test.dart @@ -0,0 +1,33 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/wallets/wallet/wallet_mixin_interfaces/nano_interface.dart'; + +void main() { + test('Nano send state uses the live account balance', () { + final state = parseNanoSendState({ + 'frontier': 'frontier', + 'representative': 'representative', + 'balance': '15', + }, BigInt.from(3)); + + expect(state.frontier, 'frontier'); + expect(state.representative, 'representative'); + expect(state.balanceAfterSend, BigInt.from(12)); + expect( + () => parseNanoSendState({'balance': '2'}, BigInt.from(3)), + throwsException, + ); + }); + + test('Nano send state surfaces error and malformed responses clearly', () { + expect( + () => parseNanoSendState({'error': 'Account not found'}, BigInt.one), + throwsA(predicate((e) => e.toString().contains('Account not found'))), + ); + expect( + () => parseNanoSendState({}, BigInt.one), + throwsA( + predicate((e) => e.toString().contains('Invalid account_info balance')), + ), + ); + }); +} diff --git a/test/wallets/peercoin_policy_test.dart b/test/wallets/peercoin_policy_test.dart new file mode 100644 index 0000000000..45877a0de8 --- /dev/null +++ b/test/wallets/peercoin_policy_test.dart @@ -0,0 +1,10 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/wallets/crypto_currency/crypto_currency.dart'; + +void main() { + test('Peercoin fallback fee uses the fixed network rate', () { + final peercoin = Peercoin(CryptoCurrencyNetwork.main); + + expect(peercoin.defaultFeeRate, BigInt.from(10000)); + }); +} diff --git a/test/wallets/restore_progress_test.dart b/test/wallets/restore_progress_test.dart new file mode 100644 index 0000000000..b1c9729ae3 --- /dev/null +++ b/test/wallets/restore_progress_test.dart @@ -0,0 +1,9 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/wallets/wallet/supporting/restore_progress.dart'; + +void main() { + test('restore progress waits for a chain height', () { + expect(calculateRestoreProgress(scannedHeight: 25, chainHeight: 0), 0); + expect(calculateRestoreProgress(scannedHeight: 25, chainHeight: 100), 0.25); + }); +} diff --git a/test/wallets/tx_data_test.dart b/test/wallets/tx_data_test.dart new file mode 100644 index 0000000000..b07748a049 --- /dev/null +++ b/test/wallets/tx_data_test.dart @@ -0,0 +1,18 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/wallets/models/tx_data.dart'; + +void main() { + test('subtractFeeFromAmount defaults and copies', () { + final txData = TxData(); + + expect(txData.subtractFeeFromAmount, false); + + final enabled = txData.copyWith(subtractFeeFromAmount: true); + expect(enabled.subtractFeeFromAmount, true); + expect(enabled.copyWith().subtractFeeFromAmount, true); + expect( + enabled.copyWith(subtractFeeFromAmount: false).subtractFeeFromAmount, + false, + ); + }); +} diff --git a/test/wallets/xelis_event_batcher_test.dart b/test/wallets/xelis_event_batcher_test.dart new file mode 100644 index 0000000000..3ba6b4e2ad --- /dev/null +++ b/test/wallets/xelis_event_batcher_test.dart @@ -0,0 +1,130 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/wallets/wallet/intermediate/xelis_event_batcher.dart'; + +const _interval = Duration(milliseconds: 20); +const _afterFlush = Duration(milliseconds: 60); + +void main() { + test('a burst of events flushes as one batch', () async { + final batches = >[]; + final batcher = XelisEventBatcher( + flushInterval: _interval, + flush: (batch) async => batches.add(batch), + ); + + batcher.queueTransaction('a'); + batcher.queueTransaction('b'); + batcher.queueTopoheightChanged(); + batcher.queueTopoheightChanged(); + batcher.queueBalanceChanged(); + + expect(batches, isEmpty); + await Future.delayed(_afterFlush); + + expect(batches, hasLength(1)); + expect(batches.single.transactions, ['a', 'b']); + expect(batches.single.topoheightChanged, isTrue); + expect(batches.single.balanceChanged, isTrue); + }); + + test('events arriving after a flush start a new batch', () async { + final batches = >[]; + final batcher = XelisEventBatcher( + flushInterval: _interval, + flush: (batch) async => batches.add(batch), + ); + + batcher.queueTransaction('a'); + await Future.delayed(_afterFlush); + batcher.queueTransaction('b'); + await Future.delayed(_afterFlush); + + expect(batches, hasLength(2)); + expect(batches[0].transactions, ['a']); + expect(batches[1].transactions, ['b']); + expect(batches[1].topoheightChanged, isFalse); + expect(batches[1].balanceChanged, isFalse); + }); + + test('no flush runs while nothing is queued', () async { + int flushCount = 0; + XelisEventBatcher( + flushInterval: _interval, + flush: (batch) async => flushCount++, + ); + + await Future.delayed(_afterFlush); + expect(flushCount, 0); + }); + + test('reset drops pending events and cancels the scheduled flush', () async { + int flushCount = 0; + final batcher = XelisEventBatcher( + flushInterval: _interval, + flush: (batch) async => flushCount++, + ); + + batcher.queueTransaction('a'); + batcher.queueBalanceChanged(); + batcher.reset(); + + await Future.delayed(_afterFlush); + expect(flushCount, 0); + + batcher.queueTransaction('b'); + await Future.delayed(_afterFlush); + expect(flushCount, 1); + }); + + test('delayed topoheight notifications read current height', () async { + int daemonHeight = 100; + int cachedHeight = 0; + int liveHeightReads = 0; + final batcher = XelisEventBatcher( + flushInterval: _interval, + flush: (batch) async { + if (batch.topoheightChanged) { + liveHeightReads++; + cachedHeight = daemonHeight; + } + }, + ); + + batcher.queueTopoheightChanged(); + daemonHeight = 101; + cachedHeight = daemonHeight; + + await Future.delayed(_afterFlush); + expect(cachedHeight, 101); + expect(liveHeightReads, 1); + }); + + test('slow flush retains one trailing batch', () async { + final releaseFirstFlush = Completer(); + final batches = >[]; + final batcher = XelisEventBatcher( + flushInterval: _interval, + flush: (batch) async { + batches.add(batch); + if (batches.length == 1) { + await releaseFirstFlush.future; + } + }, + ); + + batcher.queueTransaction('a'); + await Future.delayed(_afterFlush); + + batcher.queueTransaction('b'); + batcher.queueTransaction('c'); + await Future.delayed(_afterFlush); + expect(batches, hasLength(1)); + + releaseFirstFlush.complete(); + await Future.delayed(_afterFlush); + expect(batches, hasLength(2)); + expect(batches.last.transactions, ['b', 'c']); + }); +} diff --git a/test/wallets/xelis_operation_coordinator_test.dart b/test/wallets/xelis_operation_coordinator_test.dart new file mode 100644 index 0000000000..7f70308039 --- /dev/null +++ b/test/wallets/xelis_operation_coordinator_test.dart @@ -0,0 +1,297 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:mutex/mutex.dart'; +import 'package:stackwallet/wallets/wallet/intermediate/xelis_operation_coordinator.dart'; + +void main() { + test('concurrent refresh callers join one operation', () async { + final coordinator = XelisOperationCoordinator(Mutex()); + final releaseRefresh = Completer(); + int refreshCount = 0; + + final first = coordinator.refresh(() async { + refreshCount++; + await releaseRefresh.future; + }); + final second = coordinator.refresh(() async { + fail('the joined refresh operation must not run'); + }); + + expect(identical(first, second), isTrue); + await _flushMicrotasks(); + expect(refreshCount, 1); + expect(coordinator.activeOperation, XelisOperation.refreshing); + + releaseRefresh.complete(); + await first; + expect(coordinator.activeOperation, XelisOperation.idle); + }); + + test('refresh joins the latest queued synchronization', () async { + final coordinator = XelisOperationCoordinator(Mutex()); + final releaseRefresh = Completer(); + final releaseRescan = Completer(); + final order = []; + + final refresh = coordinator.refresh(() async { + order.add('refresh:start'); + await releaseRefresh.future; + order.add('refresh:end'); + }); + await _flushMicrotasks(); + + final rescan = coordinator.rescan(() async { + order.add('rescan:start'); + await releaseRescan.future; + order.add('rescan:end'); + }); + final joined = coordinator.refresh(() async { + fail('refresh must join the queued rescan'); + }); + + expect(identical(joined, rescan), isTrue); + releaseRefresh.complete(); + await refresh; + await _flushMicrotasks(); + expect(order, ['refresh:start', 'refresh:end', 'rescan:start']); + + releaseRescan.complete(); + await rescan; + expect(order, [ + 'refresh:start', + 'refresh:end', + 'rescan:start', + 'rescan:end', + ]); + }); + + test('distinct rescans retain their requested order', () async { + final coordinator = XelisOperationCoordinator(Mutex()); + final releaseFirst = Completer(); + final order = []; + + final first = coordinator.rescan(() async { + order.add('first:start'); + await releaseFirst.future; + order.add('first:end'); + }); + await _flushMicrotasks(); + + final second = coordinator.rescan(() async { + order.add('second'); + }); + final joined = coordinator.refresh(() async { + fail('refresh must join the most recently queued rescan'); + }); + + expect(identical(first, second), isFalse); + expect(identical(joined, second), isTrue); + releaseFirst.complete(); + await Future.wait([first, second]); + expect(order, ['first:start', 'first:end', 'second']); + }); + + test('connect, exit, connect intent is preserved without polling', () async { + final coordinator = XelisOperationCoordinator(Mutex()); + final releaseFirstConnect = Completer(); + final releaseSecondConnect = Completer(); + final order = []; + + final firstConnect = coordinator.connect(() async { + order.add('connect 1:start'); + await releaseFirstConnect.future; + order.add('connect 1:end'); + }); + await _flushMicrotasks(); + + final exit = coordinator.exit(() async { + order.add('exit'); + }); + final secondConnect = coordinator.connect(() async { + order.add('connect 2:start'); + await releaseSecondConnect.future; + order.add('connect 2:end'); + }); + final joinedConnect = coordinator.connect(() async { + fail('matching connect calls must join'); + }); + final joinedRefresh = coordinator.refresh(() async { + fail('refresh must join the connect queued after exit'); + }); + bool staleEventRan = false; + await coordinator.processEvent(() async { + staleEventRan = true; + }); + bool staleSyncEventRan = false; + await coordinator.processSyncEvent(() async { + staleSyncEventRan = true; + }); + + unawaited(joinedRefresh.then((_) => order.add('joined refresh done'))); + + expect(identical(firstConnect, secondConnect), isFalse); + expect(identical(secondConnect, joinedConnect), isTrue); + expect(staleEventRan, isFalse); + expect(staleSyncEventRan, isFalse); + + releaseFirstConnect.complete(); + await firstConnect; + await _flushMicrotasks(); + expect(order, [ + 'connect 1:start', + 'connect 1:end', + 'exit', + 'connect 2:start', + ]); + + releaseSecondConnect.complete(); + await Future.wait([exit, secondConnect, joinedRefresh]); + await _flushMicrotasks(); + expect(order.sublist(order.length - 2), [ + 'connect 2:end', + 'joined refresh done', + ]); + }); + + test('a reconnect can retain its distinct node-change intent', () async { + final coordinator = XelisOperationCoordinator(Mutex()); + final releaseFirstConnect = Completer(); + final order = []; + + final firstConnect = coordinator.connect(() async { + order.add('connect'); + await releaseFirstConnect.future; + }); + await _flushMicrotasks(); + + final reconnect = coordinator.connect(() async { + order.add('reconnect'); + }, joinExisting: false); + + expect(identical(firstConnect, reconnect), isFalse); + releaseFirstConnect.complete(); + await Future.wait([firstConnect, reconnect]); + expect(order, ['connect', 'reconnect']); + }); + + test( + 'exit suppresses new updates and releases the queue for later work', + () async { + final coordinator = XelisOperationCoordinator(Mutex()); + final releaseEvent = Completer(); + final order = []; + + final event = coordinator.processEvent(() async { + order.add('event:start'); + await releaseEvent.future; + order.add('event:end'); + }); + await _flushMicrotasks(); + + final exit = coordinator.exit(() async { + order.add('exit'); + }); + await expectLater( + coordinator.rescan(() async { + fail('rescans scheduled while exiting must not run'); + }), + throwsStateError, + ); + await coordinator.processEvent(() async { + fail('events scheduled while exiting must be ignored'); + }); + await coordinator.refresh(() async { + fail('refreshes scheduled while exiting must be ignored'); + }); + + releaseEvent.complete(); + await Future.wait([event, exit]); + + await coordinator.refresh(() async { + order.add('refresh'); + }); + expect(order, ['event:start', 'event:end', 'exit', 'refresh']); + }, + ); + + test( + 'refresh callers joining a failing connect see the same error', + () async { + final coordinator = XelisOperationCoordinator(Mutex()); + final failure = StateError('connect failed'); + final releaseConnect = Completer(); + + final connect = coordinator.connect(() async { + await releaseConnect.future; + throw failure; + }); + await _flushMicrotasks(); + + final joined = coordinator.refresh(() async { + fail('refresh must join the in-flight connect'); + }); + expect(identical(joined, connect), isTrue); + + final connectExpectation = expectLater(connect, throwsA(same(failure))); + final joinedExpectation = expectLater(joined, throwsA(same(failure))); + releaseConnect.complete(); + await Future.wait([connectExpectation, joinedExpectation]); + + bool nextRefreshRan = false; + await coordinator.refresh(() async { + nextRefreshRan = true; + }); + expect(nextRefreshRan, isTrue); + }, + ); + + test('connect-time sync events complete before exit', () async { + final coordinator = XelisOperationCoordinator(Mutex()); + const timeout = Duration(seconds: 5); + final order = []; + late Future onlineEvent; + late Future historySyncedEvent; + + final connect = coordinator.connect(() async { + order.add('connect'); + onlineEvent = coordinator.processSyncEvent(() async { + fail('the online event must join connect'); + }); + historySyncedEvent = coordinator.processSyncEvent(() async { + fail('the history synced event must join connect'); + }); + }); + await connect.timeout(timeout); + await Future.wait([onlineEvent, historySyncedEvent]).timeout(timeout); + await coordinator + .exit(() async { + order.add('exit'); + }) + .timeout(timeout); + + expect(order, ['connect', 'exit']); + }); + + test('a failed operation does not latch the queue or join state', () async { + final coordinator = XelisOperationCoordinator(Mutex()); + final failure = StateError('refresh failed'); + + await expectLater( + coordinator.refresh(() async { + throw failure; + }), + throwsA(same(failure)), + ); + + bool nextRefreshRan = false; + await coordinator.refresh(() async { + nextRefreshRan = true; + }); + + expect(nextRefreshRan, isTrue); + expect(coordinator.activeOperation, XelisOperation.idle); + }); +} + +Future _flushMicrotasks() => Future.delayed(Duration.zero); diff --git a/test/widget_tests/desktop/wallet_keys_desktop_popup_test.dart b/test/widget_tests/desktop/wallet_keys_desktop_popup_test.dart new file mode 100644 index 0000000000..6cfa28fa8e --- /dev/null +++ b/test/widget_tests/desktop/wallet_keys_desktop_popup_test.dart @@ -0,0 +1,58 @@ +import "package:flutter/material.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:stackwallet/models/isar/stack_theme.dart"; +import "package:stackwallet/pages/wallet_view/transaction_views/transaction_details_view.dart" + show IconCopyButton; +import "package:stackwallet/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/wallet_keys_desktop_popup.dart"; +import "package:stackwallet/themes/stack_colors.dart"; + +import "../../sample_data/theme_json.dart"; + +void main() { + testWidgets("shows and copies the previous FROST keys", (tester) async { + tester.view.physicalSize = const Size(1200, 1600); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget( + ProviderScope( + child: MaterialApp( + theme: ThemeData( + extensions: [ + StackColors.fromStackColorTheme( + StackTheme.fromJson(json: lightThemeJsonMap), + ), + ], + ), + home: const Scaffold( + body: WalletKeysDesktopPopup( + words: [], + walletId: "wallet", + frostData: ( + myName: "name", + keys: "current-keys", + config: "current-config", + prevGen: (keys: "previous-keys", config: "previous-config"), + ), + ), + ), + ), + ), + ); + + expect( + tester + .widgetList(find.byType(SelectableText)) + .map((widget) => widget.data), + ["current-keys", "current-config", "previous-keys", "previous-config"], + ); + expect( + tester + .widgetList(find.byType(IconCopyButton)) + .map((widget) => widget.data), + ["current-keys", "current-config", "previous-keys", "previous-config"], + ); + }); +} diff --git a/test/widgets/adaptive_text_field_test.dart b/test/widgets/adaptive_text_field_test.dart new file mode 100644 index 0000000000..336de822d2 --- /dev/null +++ b/test/widgets/adaptive_text_field_test.dart @@ -0,0 +1,61 @@ +import "package:flutter/material.dart"; +import "package:flutter/services.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:stackwallet/models/isar/stack_theme.dart"; +import "package:stackwallet/themes/stack_colors.dart"; +import "package:stackwallet/widgets/textfields/adaptive_text_field.dart"; +import "package:stackwallet/widgets/textfield_icon_button.dart"; + +import "../sample_data/theme_json.dart"; + +void main() { + testWidgets("paste trims whitespace and runs input formatters", ( + tester, + ) async { + final controller = TextEditingController(); + addTearDown(controller.dispose); + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + messenger.setMockMethodCallHandler(SystemChannels.platform, (call) async { + if (call.method == "Clipboard.getData") { + return {"text": " 12x3 "}; + } + return null; + }); + addTearDown( + () => messenger.setMockMethodCallHandler(SystemChannels.platform, null), + ); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + extensions: [ + StackColors.fromStackColorTheme( + StackTheme.fromJson(json: lightThemeJsonMap), + ), + ], + ), + home: Scaffold( + body: AdaptiveTextField( + controller: controller, + showPasteClearButton: true, + inputFormatters: [ + TextInputFormatter.withFunction((oldValue, newValue) { + final text = "[${newValue.text}]"; + return TextEditingValue( + text: text, + selection: TextSelection.collapsed(offset: text.length), + ); + }), + ], + ), + ), + ), + ); + + await tester.tap(find.byType(TextFieldIconButton)); + await tester.pump(); + + expect(controller.text, "[12x3]"); + }); +} diff --git a/test/widgets/eth_fee_form_test.dart b/test/widgets/eth_fee_form_test.dart new file mode 100644 index 0000000000..098ee536b8 --- /dev/null +++ b/test/widgets/eth_fee_form_test.dart @@ -0,0 +1,273 @@ +import "dart:convert"; +import "dart:io"; + +import "package:decimal/decimal.dart"; +import "package:flutter/material.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:stackwallet/models/isar/stack_theme.dart"; +import "package:stackwallet/networking/http.dart"; +import "package:stackwallet/services/ethereum/ethereum_api.dart"; +import "package:stackwallet/themes/stack_colors.dart"; +import "package:stackwallet/widgets/eth_fee_form.dart"; + +import "../sample_data/theme_json.dart"; + +class _GasOracleHttp extends HTTP { + const _GasOracleHttp(); + + @override + Future get({ + required Uri url, + Map? headers, + required ({InternetAddress host, int port})? proxyInfo, + Duration? connectionTimeout, + }) async => Response(const [], 500); +} + +class _SingleSuccessGasOracleHttp extends HTTP { + int requests = 0; + + @override + Future get({ + required Uri url, + Map? headers, + required ({InternetAddress host, int port})? proxyInfo, + Duration? connectionTimeout, + }) async { + requests++; + if (requests > 1) return Response(const [], 500); + + return Response( + utf8.encode( + '{"success":true,"result":{"result":{' + '"FastGasPrice":"15.678",' + '"ProposeGasPrice":"14",' + '"SafeGasPrice":"13.456",' + '"suggestBaseFee":"12.345",' + '"LastBlock":"1"}}}', + ), + 200, + ); + } +} + +void main() { + testWidgets("rejects a priority fee above the max fee", (tester) async { + final originalClient = EthereumAPI.client; + EthereumAPI.client = const _GasOracleHttp(); + addTearDown(() => EthereumAPI.client = originalClient); + + final emittedFees = []; + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + extensions: [ + StackColors.fromStackColorTheme( + StackTheme.fromJson(json: lightThemeJsonMap), + ), + ], + ), + home: Scaffold( + body: EthFeeForm( + locale: "en_US", + initialState: EthEIP1559Fee( + maxFeePerGasGwei: Decimal.fromInt(10), + maxPriorityFeePerGasGwei: Decimal.one, + gasLimit: 21000, + ), + stateChanged: emittedFees.add, + ), + ), + ), + ); + await tester.pump(); + + expect(find.text("Max fee per gas (GWEI)"), findsOneWidget); + expect(find.text("Max priority fee per gas (GWEI)"), findsOneWidget); + + final maxFeeField = find.byKey(const Key("ethMaxFeePerGasField")); + final maxPriorityFeeField = find.byKey( + const Key("ethMaxPriorityFeePerGasField"), + ); + await tester.enterText(maxPriorityFeeField, "11"); + await tester.pump(); + + expect(emittedFees.last, isNull); + expect( + tester.widget(maxPriorityFeeField).decoration!.errorText, + isNull, + ); + expect( + find.text("Max priority fee must not exceed max fee"), + findsOneWidget, + ); + + await tester.enterText(maxFeeField, "12"); + await tester.pump(); + + expect(emittedFees.last?.maxFeePerGasGwei, Decimal.fromInt(12)); + expect(emittedFees.last?.maxPriorityFeePerGasGwei, Decimal.fromInt(11)); + expect(find.text("Max priority fee must not exceed max fee"), findsNothing); + + await tester.pumpWidget(const SizedBox.shrink()); + }); + + testWidgets("gas limit preserves and rejects malformed integer text", ( + tester, + ) async { + final originalClient = EthereumAPI.client; + EthereumAPI.client = const _GasOracleHttp(); + addTearDown(() => EthereumAPI.client = originalClient); + + final emittedFees = []; + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + extensions: [ + StackColors.fromStackColorTheme( + StackTheme.fromJson(json: lightThemeJsonMap), + ), + ], + ), + home: Scaffold( + body: EthFeeForm( + locale: "en_US", + initialState: EthEIP1559Fee( + maxFeePerGasGwei: Decimal.one, + maxPriorityFeePerGasGwei: Decimal.one, + gasLimit: 21000, + ), + stateChanged: emittedFees.add, + ), + ), + ), + ); + await tester.pump(); + + final gasLimitField = find.byKey(const Key("ethFeeGasLimitField")); + await tester.enterText(gasLimitField, "21000.5"); + await tester.pump(); + + expect(tester.widget(gasLimitField).controller!.text, "21000.5"); + expect(emittedFees, [isNull]); + expect( + tester.widget(gasLimitField).decoration!.errorText, + isNull, + ); + expect( + find.text("Enter a whole number from 21000 to 30000000"), + findsOneWidget, + ); + + await tester.enterText(gasLimitField, "0x5208"); + await tester.pump(); + + expect(tester.widget(gasLimitField).controller!.text, "0x5208"); + expect(emittedFees.last, isNull); + expect( + tester.widget(gasLimitField).decoration!.errorText, + isNull, + ); + expect( + find.text("Enter a whole number from 21000 to 30000000"), + findsOneWidget, + ); + + await tester.enterText(gasLimitField, "22000"); + await tester.pump(); + + expect(emittedFees.last?.gasLimit, 22000); + expect( + tester.widget(gasLimitField).decoration!.errorText, + isNull, + ); + expect( + find.text("Enter a whole number from 21000 to 30000000"), + findsNothing, + ); + + await tester.pumpWidget(const SizedBox.shrink()); + }); + + testWidgets("locale change preserves amount field selection", (tester) async { + final originalClient = EthereumAPI.client; + EthereumAPI.client = const _GasOracleHttp(); + addTearDown(() => EthereumAPI.client = originalClient); + + Widget form(String locale) => MaterialApp( + theme: ThemeData( + extensions: [ + StackColors.fromStackColorTheme( + StackTheme.fromJson(json: lightThemeJsonMap), + ), + ], + ), + home: Scaffold( + body: EthFeeForm( + locale: locale, + initialState: EthEIP1559Fee( + maxFeePerGasGwei: Decimal.parse("12.34"), + maxPriorityFeePerGasGwei: Decimal.one, + gasLimit: 21000, + ), + stateChanged: (_) {}, + ), + ), + ); + + await tester.pumpWidget(form("en_US")); + await tester.pump(); + + final maxFeeField = find.byKey(const Key("ethMaxFeePerGasField")); + await tester.tap(maxFeeField); + await tester.pump(); + final controller = tester.widget(maxFeeField).controller!; + controller.selection = const TextSelection.collapsed(offset: 2); + + await tester.pumpWidget(form("de_DE")); + await tester.pump(); + + expect(controller.text, "12,34"); + expect(controller.selection, const TextSelection.collapsed(offset: 2)); + + await tester.pumpWidget(const SizedBox.shrink()); + }); + + testWidgets("locale change reformats cached gas oracle labels", ( + tester, + ) async { + final originalClient = EthereumAPI.client; + final client = _SingleSuccessGasOracleHttp(); + EthereumAPI.client = client; + addTearDown(() => EthereumAPI.client = originalClient); + + Widget form(String locale) => MaterialApp( + theme: ThemeData( + extensions: [ + StackColors.fromStackColorTheme( + StackTheme.fromJson(json: lightThemeJsonMap), + ), + ], + ), + home: Scaffold( + body: EthFeeForm(locale: locale, stateChanged: (_) {}), + ), + ); + + await tester.pumpWidget(form("en_US")); + await tester.pump(); + + expect(find.text("Current: 12.345 GWEI"), findsOneWidget); + expect(find.text("Current: 1.111 - 3.333 GWEI"), findsOneWidget); + expect(client.requests, 1); + + await tester.pumpWidget(form("de_DE")); + await tester.pump(); + + expect(find.text("Current: 12,345 GWEI"), findsOneWidget); + expect(find.text("Current: 1,111 - 3,333 GWEI"), findsOneWidget); + expect(client.requests, 1); + + await tester.pumpWidget(const SizedBox.shrink()); + }); +}