diff --git a/asset_sources/svg/campfire/exchange_icons/exolix.png b/asset_sources/svg/campfire/exchange_icons/exolix.png new file mode 100644 index 000000000..dfb155feb Binary files /dev/null and b/asset_sources/svg/campfire/exchange_icons/exolix.png differ diff --git a/asset_sources/svg/stack_duo/exchange_icons/exolix.png b/asset_sources/svg/stack_duo/exchange_icons/exolix.png new file mode 100644 index 000000000..dfb155feb Binary files /dev/null and b/asset_sources/svg/stack_duo/exchange_icons/exolix.png differ diff --git a/asset_sources/svg/stack_wallet/exchange_icons/exolix.png b/asset_sources/svg/stack_wallet/exchange_icons/exolix.png new file mode 100644 index 000000000..dfb155feb Binary files /dev/null and b/asset_sources/svg/stack_wallet/exchange_icons/exolix.png differ diff --git a/lib/models/isar/exchange_cache/currency.dart b/lib/models/isar/exchange_cache/currency.dart index d31c88c31..a5769e76b 100644 --- a/lib/models/isar/exchange_cache/currency.dart +++ b/lib/models/isar/exchange_cache/currency.dart @@ -13,6 +13,7 @@ import 'package:isar_community/isar.dart'; import '../../../app_config.dart'; import '../../../services/exchange/change_now/change_now_exchange.dart'; import '../../../services/exchange/exchange.dart'; +import '../../../services/exchange/exolix/exolix_exchange.dart'; import '../../../services/exchange/nanswap/nanswap_exchange.dart'; import '../../../services/exchange/trocador/trocador_exchange.dart'; import '../../../services/exchange/wizard_swap/wizard_swap_exchange.dart'; @@ -83,6 +84,8 @@ class Currency { // already lower case ticker basically const (ChangeNowExchange) => network, + const (ExolixExchange) => network.toLowerCase(), + // not used at the time being // case const (SimpleSwapExchange): diff --git a/lib/pages/exchange_view/exchange_form.dart b/lib/pages/exchange_view/exchange_form.dart index 93e651231..fb1fa41bf 100644 --- a/lib/pages/exchange_view/exchange_form.dart +++ b/lib/pages/exchange_view/exchange_form.dart @@ -30,6 +30,7 @@ import '../../services/exchange/change_now/change_now_exchange.dart'; import '../../services/exchange/exchange.dart'; import '../../services/exchange/exchange_data_loading_service.dart'; import '../../services/exchange/exchange_response.dart'; +import '../../services/exchange/exolix/exolix_exchange.dart'; import '../../services/exchange/nanswap/nanswap_exchange.dart'; import '../../services/exchange/trocador/trocador_exchange.dart'; import '../../services/exchange/wizard_swap/wizard_swap_exchange.dart'; @@ -81,6 +82,7 @@ class _ExchangeFormState extends ConsumerState { } else { return [ ChangeNowExchange.instance, + ExolixExchange.instance, TrocadorExchange.instance, NanswapExchange.instance, WizardSwapExchange.instance, diff --git a/lib/pages/exchange_view/sub_widgets/exchange_provider_option.dart b/lib/pages/exchange_view/sub_widgets/exchange_provider_option.dart index 08d6d88d4..1a3a88db9 100644 --- a/lib/pages/exchange_view/sub_widgets/exchange_provider_option.dart +++ b/lib/pages/exchange_view/sub_widgets/exchange_provider_option.dart @@ -11,7 +11,6 @@ import 'package:decimal/decimal.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_svg/svg.dart'; import '../../../app_config.dart'; import '../../../models/exchange/aggregate_currency.dart'; @@ -24,7 +23,6 @@ import '../../../themes/stack_colors.dart'; import '../../../utilities/amount/amount.dart'; import '../../../utilities/amount/amount_formatter.dart'; import '../../../utilities/amount/amount_unit.dart'; -import '../../../utilities/assets.dart'; import '../../../utilities/enums/exchange_rate_type_enum.dart'; import '../../../utilities/logger.dart'; import '../../../utilities/text_styles.dart'; @@ -37,6 +35,7 @@ import '../../../widgets/desktop/primary_button.dart'; import '../../../widgets/dialogs/basic_dialog.dart'; import '../../../widgets/exchange/trocador/trocador_kyc_info_button.dart'; import '../../../widgets/exchange/trocador/trocador_rating_type_enum.dart'; +import '../../../widgets/icon_widgets/exchange_icon.dart'; class ExchangeOption extends ConsumerStatefulWidget { const ExchangeOption({ @@ -395,25 +394,13 @@ class _ProviderOptionState extends ConsumerState { } }, errorBuilder: (context, error, stackTrace) { - return SvgPicture.asset( - Assets.exchange.getIconFor( - exchangeName: widget.exchange.name, - ), - width: isDesktop ? 32 : 24, - height: isDesktop ? 32 : 24, - ); + return ExchangeIcon(exchange: widget.exchange); }, width: isDesktop ? 32 : 24, height: isDesktop ? 32 : 24, ), ) - : SvgPicture.asset( - Assets.exchange.getIconFor( - exchangeName: widget.exchange.name, - ), - width: isDesktop ? 32 : 24, - height: isDesktop ? 32 : 24, - ), + : ExchangeIcon(exchange: widget.exchange), ), ), const SizedBox(width: 10), diff --git a/lib/pages/exchange_view/sub_widgets/exchange_provider_options.dart b/lib/pages/exchange_view/sub_widgets/exchange_provider_options.dart index a846b47cc..b7fad4d24 100644 --- a/lib/pages/exchange_view/sub_widgets/exchange_provider_options.dart +++ b/lib/pages/exchange_view/sub_widgets/exchange_provider_options.dart @@ -15,6 +15,7 @@ import '../../../models/exchange/aggregate_currency.dart'; import '../../../providers/providers.dart'; import '../../../services/exchange/change_now/change_now_exchange.dart'; import '../../../services/exchange/exchange.dart'; +import '../../../services/exchange/exolix/exolix_exchange.dart'; import '../../../services/exchange/nanswap/nanswap_exchange.dart'; import '../../../services/exchange/trocador/trocador_exchange.dart'; import '../../../services/exchange/wizard_swap/wizard_swap_exchange.dart'; @@ -97,6 +98,11 @@ class _ExchangeProviderOptionsState sendCurrency: sendCurrency, receiveCurrency: receivingCurrency, ); + final showExolix = exchangeSupported( + exchangeName: ExolixExchange.exchangeName, + sendCurrency: sendCurrency, + receiveCurrency: receivingCurrency, + ); return RoundedWhiteContainer( padding: isDesktop ? const EdgeInsets.all(0) : const EdgeInsets.all(12), @@ -106,6 +112,7 @@ class _ExchangeProviderOptionsState child: SortedExchangeProviders( exchangees: [ if (showChangeNow) ChangeNowExchange.instance, + if (showExolix) ExolixExchange.instance, if (showTrocador) TrocadorExchange.instance, if (showNanswap) NanswapExchange.instance, if (showWizardSwap) WizardSwapExchange.instance, diff --git a/lib/pages/exchange_view/trade_details_view.dart b/lib/pages/exchange_view/trade_details_view.dart index e1fe961ef..a8c201c1b 100644 --- a/lib/pages/exchange_view/trade_details_view.dart +++ b/lib/pages/exchange_view/trade_details_view.dart @@ -29,6 +29,7 @@ import '../../providers/providers.dart'; import '../../route_generator.dart'; import '../../services/exchange/change_now/change_now_exchange.dart'; import '../../services/exchange/exchange.dart'; +import '../../services/exchange/exolix/exolix_exchange.dart'; import '../../services/exchange/nanswap/nanswap_exchange.dart'; import '../../services/exchange/simpleswap/simpleswap_exchange.dart'; import '../../services/exchange/trocador/trocador_exchange.dart'; @@ -119,17 +120,21 @@ class _TradeDetailsViewState extends ConsumerState { String _fetchIconAssetForStatus(String statusString, IThemeAssets assets) { ChangeNowTransactionStatus? status; try { - if (statusString.toLowerCase().startsWith("waiting")) { + if (statusString.toLowerCase().startsWith("waiting") || + statusString.toLowerCase() == "wait") { statusString = "Waiting"; } status = changeNowTransactionStatusFromStringIgnoreCase(statusString); } on ArgumentError catch (_) { switch (statusString.toLowerCase()) { + case "confirmed": // exolix case + case "confirmation": // exolix case case "funds confirming": case "processing payment": return assets.txExchangePending; case "completed": + case "success": // exolix case return assets.txExchange; default: @@ -168,6 +173,7 @@ class _TradeDetailsViewState extends ConsumerState { sentFromStack || !(trade.status == "New" || trade.status == "new" || + trade.status == "wait" || trade.status == "Waiting" || trade.status == "waiting" || trade.status == "Refunded" || @@ -178,6 +184,7 @@ class _TradeDetailsViewState extends ConsumerState { trade.status == "expired" || trade.status == "Failed" || trade.status == "failed" || + trade.status == "overdue" || trade.status.toLowerCase().startsWith("waiting")); //todo: check if print needed @@ -202,6 +209,7 @@ class _TradeDetailsViewState extends ConsumerState { (trade.status == "New" || trade.status == "new" || trade.status == "waiting" || + trade.status == "wait" || trade.status == "Waiting"); return ConditionalParent( @@ -1162,6 +1170,10 @@ class _TradeDetailsViewState extends ConsumerState { url = "https://www.wizardswap.io/api/exchange/${trade.tradeId}"; break; + case ExolixExchange.exchangeName: + url = + "https://exolix.com/transaction/${trade.tradeId}"; + break; default: if (trade.exchangeName.startsWith( diff --git a/lib/services/exchange/exchange.dart b/lib/services/exchange/exchange.dart index c2c819182..85a3f8f52 100644 --- a/lib/services/exchange/exchange.dart +++ b/lib/services/exchange/exchange.dart @@ -16,6 +16,7 @@ import '../../models/exchange/response_objects/trade.dart'; import '../../models/isar/exchange_cache/currency.dart'; import 'change_now/change_now_exchange.dart'; import 'exchange_response.dart'; +import 'exolix/exolix_exchange.dart'; import 'nanswap/nanswap_exchange.dart'; import 'simpleswap/simpleswap_exchange.dart'; import 'trocador/trocador_exchange.dart'; @@ -38,6 +39,8 @@ abstract class Exchange { return NanswapExchange.instance; case WizardSwapExchange.exchangeName: return WizardSwapExchange.instance; + case ExolixExchange.exchangeName: + return ExolixExchange.instance; default: final split = name.split(" "); if (split.length >= 2) { @@ -110,6 +113,7 @@ abstract class Exchange { static List get exchangesWithTorSupport => [ // MajesticBankExchange.instance, TrocadorExchange.instance, + ExolixExchange.instance, // Maybe?? NanswapExchange.instance, // Maybe?? ]; diff --git a/lib/services/exchange/exchange_data_loading_service.dart b/lib/services/exchange/exchange_data_loading_service.dart index 29645bfb0..4f067f8cf 100644 --- a/lib/services/exchange/exchange_data_loading_service.dart +++ b/lib/services/exchange/exchange_data_loading_service.dart @@ -25,6 +25,7 @@ import '../../utilities/logger.dart'; import '../../utilities/prefs.dart'; import '../../utilities/stack_file_system.dart'; import 'change_now/change_now_exchange.dart'; +import 'exolix/exolix_exchange.dart'; import 'nanswap/nanswap_exchange.dart'; import 'trocador/trocador_exchange.dart'; import 'wizard_swap/wizard_swap_exchange.dart'; @@ -209,6 +210,7 @@ class ExchangeDataLoadingService { loadTrocadorCurrencies(), loadNanswapCurrencies(), loadWizardSwapCurrencies(), + loadExolixCurrencies(), ]; // If using Tor, don't load data for exchanges which don't support Tor. @@ -460,6 +462,29 @@ class ExchangeDataLoadingService { } } + Future loadExolixCurrencies() async { + if (_isar == null) { + await initDB(); + } + final responseCurrencies = await ExolixExchange.instance.getAllCurrencies( + false, + ); + + if (responseCurrencies.value != null) { + await (await isar).writeTxn(() async { + final idsToDelete = await (await isar).currencies + .where() + .exchangeNameEqualTo(ExolixExchange.exchangeName) + .idProperty() + .findAll(); + await (await isar).currencies.deleteAll(idsToDelete); + await (await isar).currencies.putAll(responseCurrencies.value!); + }); + } else { + Logging.instance.w("loadExolixCurrencies: $responseCurrencies"); + } + } + // Future loadMajesticBankPairs() async { // final exchange = MajesticBankExchange.instance; // diff --git a/lib/services/exchange/exolix/api/dto/exolix_base_dto.dart b/lib/services/exchange/exolix/api/dto/exolix_base_dto.dart new file mode 100644 index 000000000..0ae2df48b --- /dev/null +++ b/lib/services/exchange/exolix/api/dto/exolix_base_dto.dart @@ -0,0 +1,6 @@ +abstract class ExolixBaseDto { + Map toMap(); + + @override + String toString() => toMap().toString(); +} diff --git a/lib/services/exchange/exolix/api/dto/exolix_coin_info.dart b/lib/services/exchange/exolix/api/dto/exolix_coin_info.dart new file mode 100644 index 000000000..eaa61be83 --- /dev/null +++ b/lib/services/exchange/exolix/api/dto/exolix_coin_info.dart @@ -0,0 +1,51 @@ +import 'exolix_base_dto.dart'; + +/// The "coinFrom" / "coinTo" sub-object inside a transaction. +class ExolixCoinInfo extends ExolixBaseDto { + final String coinCode; + final String coinName; + final String network; + final String networkName; + final String? networkShortName; + final String? icon; + final String? memoName; + final String? contract; + + ExolixCoinInfo({ + required this.coinCode, + required this.coinName, + required this.network, + required this.networkName, + required this.networkShortName, + required this.icon, + required this.memoName, + required this.contract, + }); + + factory ExolixCoinInfo.fromJson(Map json) { + return ExolixCoinInfo( + coinCode: json["coinCode"] as String? ?? "", + coinName: json["coinName"] as String? ?? "", + network: json["network"] as String? ?? "", + networkName: json["networkName"] as String? ?? "", + networkShortName: json["networkShortName"] as String?, + icon: json["icon"] as String?, + memoName: json["memoName"] as String?, + contract: json["contract"] as String?, + ); + } + + @override + Map toMap() { + return { + "coinCode": coinCode, + "coinName": coinName, + "network": network, + "networkName": networkName, + "networkShortName": networkShortName, + "icon": icon, + "memoName": memoName, + "contract": contract, + }; + } +} diff --git a/lib/services/exchange/exolix/api/dto/exolix_currency.dart b/lib/services/exchange/exolix/api/dto/exolix_currency.dart new file mode 100644 index 000000000..57091ecf4 --- /dev/null +++ b/lib/services/exchange/exolix/api/dto/exolix_currency.dart @@ -0,0 +1,51 @@ +import 'exolix_base_dto.dart'; +import 'exolix_network.dart'; + +/// A currency entry. +class ExolixCurrency extends ExolixBaseDto { + final String code; + final String name; + final String? icon; + final String? notes; + + /// Only populated when the listing was requested with withNetworks=true. + final List networks; + + ExolixCurrency({ + required this.code, + required this.name, + required this.icon, + required this.notes, + required this.networks, + }); + + factory ExolixCurrency.fromJson(Map json) { + final dynamic rawNetworks = json["networks"]; + final List nets = (rawNetworks is List) + ? rawNetworks + .map( + (e) => + ExolixNetwork.fromJson(Map.from(e as Map)), + ) + .toList() + : []; + return ExolixCurrency( + code: json["code"] as String? ?? "", + name: json["name"] as String? ?? "", + icon: json["icon"] as String?, + notes: json["notes"] as String?, + networks: nets, + ); + } + + @override + Map toMap() { + return { + "code": code, + "name": name, + "icon": icon, + "notes": notes, + "networks": networks.map((n) => n.toMap()).toList(), + }; + } +} diff --git a/lib/services/exchange/exolix/api/dto/exolix_hash.dart b/lib/services/exchange/exolix/api/dto/exolix_hash.dart new file mode 100644 index 000000000..147bf92e0 --- /dev/null +++ b/lib/services/exchange/exolix/api/dto/exolix_hash.dart @@ -0,0 +1,21 @@ +import 'exolix_base_dto.dart'; + +/// A transaction hash sub-object (hashIn / hashOut). +class ExolixHash extends ExolixBaseDto { + final String? hash; + final String? link; + + ExolixHash({required this.hash, required this.link}); + + factory ExolixHash.fromJson(Map json) { + return ExolixHash( + hash: json["hash"] as String?, + link: json["link"] as String?, + ); + } + + @override + Map toMap() { + return {"hash": hash, "link": link}; + } +} diff --git a/lib/services/exchange/exolix/api/dto/exolix_network.dart b/lib/services/exchange/exolix/api/dto/exolix_network.dart new file mode 100644 index 000000000..4d4091cda --- /dev/null +++ b/lib/services/exchange/exolix/api/dto/exolix_network.dart @@ -0,0 +1,97 @@ +import 'exolix_base_dto.dart'; + +/// A network entry as returned in currency listings and the dedicated +/// networks endpoints. +class ExolixNetwork extends ExolixBaseDto { + final String network; + final String name; + final String? shortName; + final String? notes; + final String? addressRegex; + final bool isDefault; + final String? blockExplorer; + final bool memoNeeded; + final String? memoName; + final String? memoRegex; + final int precision; + final int? decimal; + final String? contract; + final String? icon; + + ExolixNetwork({ + required this.network, + required this.name, + required this.shortName, + required this.notes, + required this.addressRegex, + required this.isDefault, + required this.blockExplorer, + required this.memoNeeded, + required this.memoName, + required this.memoRegex, + required this.precision, + required this.decimal, + required this.contract, + required this.icon, + }); + + factory ExolixNetwork.fromJson(Map json) { + // The docs are inconsistent: one example uses "addresRegex" (typo), + // another uses "addressRegex". Accept both. + final dynamic addrRegex = json["addressRegex"] ?? json["addresRegex"]; + return ExolixNetwork( + network: json["network"] as String? ?? "", + name: json["name"] as String? ?? "", + shortName: json["shortName"] as String?, + notes: json["notes"] as String?, + addressRegex: addrRegex as String?, + isDefault: json["isDefault"] as bool? ?? false, + blockExplorer: json["blockExplorer"] as String?, + memoNeeded: json["memoNeeded"] as bool? ?? false, + memoName: json["memoName"] as String?, + memoRegex: json["memoRegex"] as String?, + precision: _parseInt(json["precision"]), + decimal: json["decimal"] == null ? null : _parseInt(json["decimal"]), + contract: json["contract"] as String?, + icon: json["icon"] as String?, + ); + } + + @override + Map toMap() { + return { + "network": network, + "name": name, + "shortName": shortName, + "notes": notes, + "addressRegex": addressRegex, + "isDefault": isDefault, + "blockExplorer": blockExplorer, + "memoNeeded": memoNeeded, + "memoName": memoName, + "memoRegex": memoRegex, + "precision": precision, + "decimal": decimal, + "contract": contract, + "icon": icon, + }; + } +} + +int _parseInt(dynamic value) { + if (value is int) return value; + if (value is double) return value.toInt(); + if (value is String) { + final parsedInt = int.tryParse(value); + if (parsedInt != null) return parsedInt; + throw FormatException( + "Expected an integer value but got unparseable string", + value, + ); + } + throw FormatException( + "Expected an integer value (int, or numeric String) but got" + " ${value.runtimeType}", + "$value", + ); +} diff --git a/lib/services/exchange/exolix/api/dto/exolix_rate.dart b/lib/services/exchange/exolix/api/dto/exolix_rate.dart new file mode 100644 index 000000000..fc1656d0e --- /dev/null +++ b/lib/services/exchange/exolix/api/dto/exolix_rate.dart @@ -0,0 +1,53 @@ +import 'package:decimal/decimal.dart'; + +import '../helpers/parse_decimal.dart'; +import 'exolix_base_dto.dart'; + +/// Exchange rate quote. +/// +/// All numeric fields are [Decimal] to preserve precision for coin amounts +/// and exchange rates. +class ExolixRate extends ExolixBaseDto { + final Decimal fromAmount; + final Decimal toAmount; + final Decimal rate; + final String? message; + final Decimal minAmount; + final Decimal withdrawMin; + final Decimal maxAmount; + + ExolixRate({ + required this.fromAmount, + required this.toAmount, + required this.rate, + required this.message, + required this.minAmount, + required this.withdrawMin, + required this.maxAmount, + }); + + factory ExolixRate.fromJson(Map json) { + return ExolixRate( + fromAmount: parseDecimal(json["fromAmount"]), + toAmount: parseDecimal(json["toAmount"]), + rate: parseDecimal(json["rate"]), + message: json["message"] as String?, + minAmount: parseDecimal(json["minAmount"]), + withdrawMin: parseDecimal(json["withdrawMin"]), + maxAmount: parseDecimal(json["maxAmount"]), + ); + } + + @override + Map toMap() { + return { + "fromAmount": fromAmount.toString(), + "toAmount": toAmount.toString(), + "rate": rate.toString(), + "message": message, + "minAmount": minAmount.toString(), + "withdrawMin": withdrawMin.toString(), + "maxAmount": maxAmount.toString(), + }; + } +} diff --git a/lib/services/exchange/exolix/api/dto/exolix_transaction.dart b/lib/services/exchange/exolix/api/dto/exolix_transaction.dart new file mode 100644 index 000000000..1b1e98231 --- /dev/null +++ b/lib/services/exchange/exolix/api/dto/exolix_transaction.dart @@ -0,0 +1,152 @@ +import 'package:decimal/decimal.dart'; + +import '../helpers/enums.dart'; +import '../helpers/parse_decimal.dart'; +import 'exolix_base_dto.dart'; +import 'exolix_coin_info.dart'; +import 'exolix_hash.dart'; + +/// A full transaction object. +/// +/// Coin amounts and the exchange rate are [Decimal] for precision. +class ExolixTransaction extends ExolixBaseDto { + final String id; + final Decimal amount; + final Decimal amountTo; + final ExolixCoinInfo coinFrom; + final ExolixCoinInfo coinTo; + final String? comment; + final DateTime? createdAt; + final String depositAddress; + final String? depositExtraId; + final String withdrawalAddress; + final String? withdrawalExtraId; + final ExolixHash hashIn; + final ExolixHash hashOut; + final Decimal rate; + final ExolixRateType rateType; + final String? refundAddress; + final String? refundExtraId; + final ExolixTransactionStatus status; + + /// "source" is documented for the listing endpoint but not for the single + /// fetch. Nullable so it round-trips safely either way. + final String? source; + + ExolixTransaction({ + required this.id, + required this.amount, + required this.amountTo, + required this.coinFrom, + required this.coinTo, + required this.comment, + required this.createdAt, + required this.depositAddress, + required this.depositExtraId, + required this.withdrawalAddress, + required this.withdrawalExtraId, + required this.hashIn, + required this.hashOut, + required this.rate, + required this.rateType, + required this.refundAddress, + required this.refundExtraId, + required this.status, + required this.source, + }); + + factory ExolixTransaction.fromJson(Map json) { + final dynamic coinFromRaw = json["coinFrom"]; + final dynamic coinToRaw = json["coinTo"]; + final dynamic hashInRaw = json["hashIn"]; + final dynamic hashOutRaw = json["hashOut"]; + + DateTime? parsedCreatedAt; + final dynamic createdAtRaw = json["createdAt"]; + if (createdAtRaw is String && createdAtRaw.isNotEmpty) { + parsedCreatedAt = DateTime.tryParse(createdAtRaw); + } + + ExolixRateType parsedRateType; + final dynamic rateTypeRaw = json["rateType"]; + if (rateTypeRaw == "float") { + parsedRateType = ExolixRateType.float; + } else { + // Default per docs is fixed. + parsedRateType = ExolixRateType.fixed; + } + + return ExolixTransaction( + id: json["id"] as String? ?? "", + amount: parseDecimal(json["amount"]), + amountTo: parseDecimal(json["amountTo"]), + coinFrom: (coinFromRaw is Map) + ? ExolixCoinInfo.fromJson(Map.from(coinFromRaw)) + : ExolixCoinInfo( + coinCode: "", + coinName: "", + network: "", + networkName: "", + networkShortName: null, + icon: null, + memoName: null, + contract: null, + ), + coinTo: (coinToRaw is Map) + ? ExolixCoinInfo.fromJson(Map.from(coinToRaw)) + : ExolixCoinInfo( + coinCode: "", + coinName: "", + network: "", + networkName: "", + networkShortName: null, + icon: null, + memoName: null, + contract: null, + ), + comment: json["comment"] as String?, + createdAt: parsedCreatedAt, + depositAddress: json["depositAddress"] as String? ?? "", + depositExtraId: json["depositExtraId"] as String?, + withdrawalAddress: json["withdrawalAddress"] as String? ?? "", + withdrawalExtraId: json["withdrawalExtraId"] as String?, + hashIn: (hashInRaw is Map) + ? ExolixHash.fromJson(Map.from(hashInRaw)) + : ExolixHash(hash: null, link: null), + hashOut: (hashOutRaw is Map) + ? ExolixHash.fromJson(Map.from(hashOutRaw)) + : ExolixHash(hash: null, link: null), + rate: parseDecimal(json["rate"]), + rateType: parsedRateType, + refundAddress: json["refundAddress"] as String?, + refundExtraId: json["refundExtraId"] as String?, + status: ExolixTransactionStatus.fromString(json["status"] as String?), + source: json["source"] as String?, + ); + } + + @override + Map toMap() { + return { + "id": id, + "amount": amount.toString(), + "amountTo": amountTo.toString(), + "coinFrom": coinFrom.toMap(), + "coinTo": coinTo.toMap(), + "comment": comment, + "createdAt": createdAt?.toIso8601String(), + "depositAddress": depositAddress, + "depositExtraId": depositExtraId, + "withdrawalAddress": withdrawalAddress, + "withdrawalExtraId": withdrawalExtraId, + "hashIn": hashIn.toMap(), + "hashOut": hashOut.toMap(), + "rate": rate.toString(), + "rateType": rateType.apiValue, + "refundAddress": refundAddress, + "refundExtraId": refundExtraId, + "status": status.name, + "source": source, + }; + } +} diff --git a/lib/services/exchange/exolix/api/exolix_api.dart b/lib/services/exchange/exolix/api/exolix_api.dart new file mode 100644 index 000000000..19b4291b0 --- /dev/null +++ b/lib/services/exchange/exolix/api/exolix_api.dart @@ -0,0 +1,515 @@ +import "dart:convert"; +import "dart:io"; + +import "package:decimal/decimal.dart"; +import "package:flutter/material.dart"; + +import "../../../../app_config.dart"; +import "../../../../external_api_keys.dart"; +import "../../../../networking/http.dart"; +import "../../../../utilities/prefs.dart"; +import "../../../tor_service.dart"; +import "dto/exolix_currency.dart"; +import "dto/exolix_network.dart"; +import "dto/exolix_rate.dart"; +import "dto/exolix_transaction.dart"; +import "helpers/enums.dart"; +import "helpers/exolix_paginated_response.dart"; + +class ExolixApiException implements Exception { + final int? statusCode; + final String message; + final dynamic body; + + ExolixApiException({required this.message, this.statusCode, this.body}); + + @override + String toString() => + "ExolixApiException(" + "statusCode: $statusCode, " + "message: $message, " + "body: $body)"; +} + +class ExolixApi { + ExolixApi._(); + + static const String _baseUrl = "https://exolix.com/api/v2"; + + /// Override to inject a mock client in tests. + static HTTP _client = const HTTP(); + + // ignore: avoid_setters_without_getters + @visibleForTesting + static set client(HTTP client) { + _client = client; + } + + /// Resolves the API key to use for a request. If [override] is null OR an + /// empty/whitespace-only string, falls back to [kExolixApiKey]. + static String _resolveApiKey(String? override) { + if (override == null) return kExolixApiKey; + final trimmed = override.trim(); + if (trimmed.isEmpty) return kExolixApiKey; + return trimmed; + } + + /// Builds the standard headers. The Authorization header is only attached + /// when the resolved key is non-empty AND not the literal placeholder. + /// Many endpoints work unauthenticated, so we must not send a useless + /// header that could be rejected by the server. + static Map _buildHeaders(String? apiKey) { + final headers = { + "Accept": "application/json", + "Content-Type": "application/json", + }; + final key = _resolveApiKey(apiKey); + if (key.isNotEmpty && key != "YOUR_API_KEY_HERE") { + headers["Authorization"] = key; + } + return headers; + } + + /// Encodes a query parameter map, dropping null values. All values are + /// stringified because Uri requires String values. [Decimal] values are + /// rendered via their canonical [Decimal.toString()] for lossless transport. + static Map _encodeQuery(Map raw) { + final out = {}; + raw.forEach((key, value) { + if (value == null) return; + if (value is bool) { + out[key] = value ? "true" : "false"; + } else if (value is Decimal) { + out[key] = value.toString(); + } else { + out[key] = value.toString(); + } + }); + return out; + } + + /// Builds a URI for a path under the base URL with optional query params. + static Uri _buildUri(String path, [Map? query]) { + final fullPath = path.startsWith("/") ? path : "/$path"; + final base = Uri.parse("$_baseUrl$fullPath"); + if (query == null || query.isEmpty) { + return base; + } + final encoded = _encodeQuery(query); + if (encoded.isEmpty) { + return base; + } + return base.replace(queryParameters: encoded); + } + + /// Resolve the proxy info to use for a request based on app config + prefs. + /// Returns null when the Tor feature is disabled or when the user has not + /// opted in to Tor in prefs. + static ({InternetAddress host, int port})? _resolveProxyInfo() { + if (!AppConfig.hasFeature(AppFeature.tor)) { + return null; + } + if (Prefs.instance.useTor) { + return TorService.sharedInstance.getProxyInfo(); + } + return null; + } + + /// Encodes a request body, serializing [Decimal] values as raw JSON + /// numbers (not strings) so the wire format matches the API examples. + /// We do this by emitting the JSON manually for top-level fields, since + /// jsonEncode's `toEncodable` can only return objects, not raw tokens. + /// + /// The body is a flat Map in this API, which keeps the + /// implementation simple. If you ever nest Decimals deeper, extend this. + static String _encodeBody(Map body) { + final buffer = StringBuffer("{"); + var first = true; + body.forEach((key, value) { + if (!first) buffer.write(","); + first = false; + buffer.write(jsonEncode(key)); + buffer.write(":"); + if (value is Decimal) { + // Emit as a raw JSON number using Decimal's canonical string form. + // Decimal.toString() never produces exponent form for finite values + // and always yields a valid JSON number. + buffer.write(value.toString()); + } else { + buffer.write(jsonEncode(value)); + } + }); + buffer.write("}"); + return buffer.toString(); + } + + /// Parse a response body and check status. Throws [ExolixApiException] on + /// non-2xx. Returns the decoded body (Map or List), or the raw String body + /// if it wasn't JSON-parseable. + static dynamic _parseResponse( + int status, + String body, + String endpointForError, + ) { + dynamic decoded; + if (body.isNotEmpty) { + try { + decoded = jsonDecode(body); + } catch (_) { + decoded = body; + } + } + if (status < 200 || status >= 300) { + String message; + if (decoded is Map && decoded["message"] is String) { + message = decoded["message"] as String; + } else if (decoded is Map && decoded["error"] is String) { + message = decoded["error"] as String; + } else { + message = "Request failed with status $status for $endpointForError"; + } + throw ExolixApiException( + statusCode: status, + message: message, + body: decoded, + ); + } + return decoded; + } + + /// Issues a GET and returns the decoded body. Throws on non-2xx. + static Future _get(Uri uri, String? apiKey) async { + final response = await _client.get( + url: uri, + headers: _buildHeaders(apiKey), + proxyInfo: _resolveProxyInfo(), + ); + return _parseResponse(response.code, response.body, uri.path); + } + + /// Issues a POST and returns the decoded body. Throws on non-2xx. + static Future _post( + Uri uri, + String? apiKey, + Map jsonBody, + ) async { + final response = await _client.post( + url: uri, + headers: _buildHeaders(apiKey), + body: _encodeBody(jsonBody), + proxyInfo: _resolveProxyInfo(), + ); + return _parseResponse(response.code, response.body, uri.path); + } + + // -------------------------------------------------------- + // Currencies + // -------------------------------------------------------- + + /// GET /currencies + static Future> getCurrencies({ + int? page, + int? size, + String? search, + bool? withNetworks, + String? apiKey, + }) async { + final uri = _buildUri("/currencies", { + "page": page, + "size": size, + "search": search, + "withNetworks": withNetworks, + }); + final result = await _get(uri, apiKey); + if (result is! Map) { + throw ExolixApiException( + message: "Unexpected response shape for /currencies", + body: result, + ); + } + return ExolixPaginatedResponse.fromJson( + Map.from(result), + ExolixCurrency.fromJson, + ); + } + + /// GET /currencies/{code}/networks + static Future> getCurrencyNetworks({ + required String code, + String? apiKey, + }) async { + if (code.trim().isEmpty) { + throw ArgumentError.value(code, "code", "must not be empty"); + } + final uri = _buildUri("/currencies/${Uri.encodeComponent(code)}/networks"); + final result = await _get(uri, apiKey); + if (result is! List) { + throw ExolixApiException( + message: "Unexpected response shape for /currencies/$code/networks", + body: result, + ); + } + return result + .map((e) => ExolixNetwork.fromJson(Map.from(e as Map))) + .toList(); + } + + /// GET /currencies/networks + static Future> getAllNetworks({ + int? page, + int? size, + String? search, + String? apiKey, + }) async { + final uri = _buildUri("/currencies/networks", { + "page": page, + "size": size, + "search": search, + }); + final result = await _get(uri, apiKey); + if (result is! Map) { + throw ExolixApiException( + message: "Unexpected response shape for /currencies/networks", + body: result, + ); + } + return ExolixPaginatedResponse.fromJson( + Map.from(result), + ExolixNetwork.fromJson, + ); + } + + // -------------------------------------------------------- + // Rate + // -------------------------------------------------------- + + /// GET /rate + /// + /// You must supply EXACTLY ONE of [amount] or [withdrawalAmount]. Supplying + /// neither or both throws [ArgumentError]. Both are coin amounts and use + /// [Decimal] for precision. + static Future getRate({ + required String coinFrom, + required String coinTo, + String? networkFrom, + String? networkTo, + Decimal? amount, + Decimal? withdrawalAmount, + ExolixRateType rateType = ExolixRateType.fixed, + String? apiKey, + }) async { + if (coinFrom.trim().isEmpty) { + throw ArgumentError.value(coinFrom, "coinFrom", "must not be empty"); + } + if (coinTo.trim().isEmpty) { + throw ArgumentError.value(coinTo, "coinTo", "must not be empty"); + } + final hasAmount = amount != null; + final hasWithdraw = withdrawalAmount != null; + if (!hasAmount && !hasWithdraw) { + throw ArgumentError("Must supply either amount or withdrawalAmount."); + } + if (hasAmount && hasWithdraw) { + throw ArgumentError( + "Supply only one of amount or withdrawalAmount, not both.", + ); + } + if (amount != null && amount <= Decimal.zero) { + throw ArgumentError.value(amount, "amount", "must be positive"); + } + if (withdrawalAmount != null && withdrawalAmount <= Decimal.zero) { + throw ArgumentError.value( + withdrawalAmount, + "withdrawalAmount", + "must be positive", + ); + } + + final uri = _buildUri("/rate", { + "coinFrom": coinFrom, + "coinTo": coinTo, + "networkFrom": networkFrom, + "networkTo": networkTo, + "amount": amount, + "withdrawalAmount": withdrawalAmount, + "rateType": rateType.apiValue, + }); + final result = await _get(uri, apiKey); + if (result is! Map) { + throw ExolixApiException( + message: "Unexpected response shape for /rate", + body: result, + ); + } + return ExolixRate.fromJson(Map.from(result)); + } + + // -------------------------------------------------------- + // Transactions + // -------------------------------------------------------- + + /// GET /transactions + static Future> getTransactions({ + int? page, + int? size, + String? search, + String? sort, + String? order, + DateTime? dateFrom, + DateTime? dateTo, + String? statuses, + String? apiKey, + }) async { + if (order != null) { + final normalized = order.toLowerCase(); + if (normalized != "asc" && normalized != "desc") { + throw ArgumentError.value(order, "order", "must be 'asc' or 'desc'"); + } + } + final uri = _buildUri("/transactions", { + "page": page, + "size": size, + "search": search, + "sort": sort, + "order": order?.toLowerCase(), + "dateFrom": dateFrom?.toUtc().toIso8601String(), + "dateTo": dateTo?.toUtc().toIso8601String(), + "statuses": statuses, + }); + final result = await _get(uri, apiKey); + if (result is! Map) { + throw ExolixApiException( + message: "Unexpected response shape for /transactions", + body: result, + ); + } + return ExolixPaginatedResponse.fromJson( + Map.from(result), + ExolixTransaction.fromJson, + ); + } + + /// GET /transactions/{id} + static Future getTransaction({ + required String id, + String? apiKey, + }) async { + if (id.trim().isEmpty) { + throw ArgumentError.value(id, "id", "must not be empty"); + } + final uri = _buildUri("/transactions/${Uri.encodeComponent(id)}"); + final result = await _get(uri, apiKey); + if (result is! Map) { + throw ExolixApiException( + message: "Unexpected response shape for /transactions/$id", + body: result, + ); + } + return ExolixTransaction.fromJson(Map.from(result)); + } + + /// POST /transactions + /// + /// Exactly one of [amount] / [withdrawalAmount] must be supplied — both are + /// coin amounts and use [Decimal]. If [slippage] is supplied, + /// [refundAddress] is required (per the docs). [slippage] is a percentage, + /// not a money value, so it stays a [double]. + static Future createTransaction({ + required String coinFrom, + required String networkFrom, + required String coinTo, + required String networkTo, + required String withdrawalAddress, + Decimal? amount, + Decimal? withdrawalAmount, + String? withdrawalExtraId, + ExolixRateType rateType = ExolixRateType.fixed, + String? refundAddress, + String? refundExtraId, + double? slippage, + String? apiKey, + }) async { + if (coinFrom.trim().isEmpty) { + throw ArgumentError.value(coinFrom, "coinFrom", "must not be empty"); + } + if (networkFrom.trim().isEmpty) { + throw ArgumentError.value( + networkFrom, + "networkFrom", + "must not be empty", + ); + } + if (coinTo.trim().isEmpty) { + throw ArgumentError.value(coinTo, "coinTo", "must not be empty"); + } + if (networkTo.trim().isEmpty) { + throw ArgumentError.value(networkTo, "networkTo", "must not be empty"); + } + if (withdrawalAddress.trim().isEmpty) { + throw ArgumentError.value( + withdrawalAddress, + "withdrawalAddress", + "must not be empty", + ); + } + + final hasAmount = amount != null; + final hasWithdraw = withdrawalAmount != null; + if (!hasAmount && !hasWithdraw) { + throw ArgumentError("Must supply either amount or withdrawalAmount."); + } + if (hasAmount && hasWithdraw) { + throw ArgumentError( + "Supply only one of amount or withdrawalAmount, not both.", + ); + } + if (amount != null && amount <= Decimal.zero) { + throw ArgumentError.value(amount, "amount", "must be positive"); + } + if (withdrawalAmount != null && withdrawalAmount <= Decimal.zero) { + throw ArgumentError.value( + withdrawalAmount, + "withdrawalAmount", + "must be positive", + ); + } + + if (slippage != null) { + if (slippage < 0) { + throw ArgumentError.value(slippage, "slippage", "must be non-negative"); + } + if (refundAddress == null || refundAddress.trim().isEmpty) { + throw ArgumentError( + "refundAddress is required when slippage is provided.", + ); + } + } + + final body = { + "coinFrom": coinFrom, + "networkFrom": networkFrom, + "coinTo": coinTo, + "networkTo": networkTo, + "withdrawalAddress": withdrawalAddress, + "rateType": rateType.apiValue, + }; + if (amount != null) body["amount"] = amount; + if (withdrawalAmount != null) body["withdrawalAmount"] = withdrawalAmount; + if (withdrawalExtraId != null) { + body["withdrawalExtraId"] = withdrawalExtraId; + } + if (refundAddress != null) body["refundAddress"] = refundAddress; + if (refundExtraId != null) body["refundExtraId"] = refundExtraId; + if (slippage != null) body["slippage"] = slippage; + + final uri = _buildUri("/transactions"); + final result = await _post(uri, apiKey, body); + if (result is! Map) { + throw ExolixApiException( + message: "Unexpected response shape for POST /transactions", + body: result, + ); + } + return ExolixTransaction.fromJson(Map.from(result)); + } +} diff --git a/lib/services/exchange/exolix/api/helpers/enums.dart b/lib/services/exchange/exolix/api/helpers/enums.dart new file mode 100644 index 000000000..90c6809fb --- /dev/null +++ b/lib/services/exchange/exolix/api/helpers/enums.dart @@ -0,0 +1,37 @@ +/// The rate type for an exchange. +enum ExolixRateType { + fixed, + float; + + String get apiValue => switch (this) { + .fixed => "fixed", + .float => "float", + }; +} + +/// Transaction status returned by the API. +enum ExolixTransactionStatus { + wait, + confirmation, + confirmed, + exchanging, + sending, + success, + overdue, + refund, + refunded, + unknown; + + static ExolixTransactionStatus fromString(String? value) => switch (value) { + "wait" => .wait, + "confirmation" => .confirmation, + "confirmed" => .confirmed, + "exchanging" => .exchanging, + "sending" => .sending, + "success" => .success, + "overdue" => .overdue, + "refund" => .refund, + "refunded" => .refunded, + _ => .unknown, + }; +} diff --git a/lib/services/exchange/exolix/api/helpers/exolix_paginated_response.dart b/lib/services/exchange/exolix/api/helpers/exolix_paginated_response.dart new file mode 100644 index 000000000..28a22ab4d --- /dev/null +++ b/lib/services/exchange/exolix/api/helpers/exolix_paginated_response.dart @@ -0,0 +1,37 @@ +import '../dto/exolix_base_dto.dart'; + +class ExolixPaginatedResponse { + final List data; + final int count; + + ExolixPaginatedResponse({required this.data, required this.count}); + + factory ExolixPaginatedResponse.fromJson( + Map json, + T Function(Map) itemFromJson, + ) { + final dynamic rawData = json["data"]; + final List items = (rawData is List) + ? rawData + .map((e) => itemFromJson(Map.from(e as Map))) + .toList() + : []; + return ExolixPaginatedResponse( + data: items, + count: int.parse(json["count"].toString()), + ); + } + + Map toMap() { + return { + "data": data.map((e) { + if (e is ExolixBaseDto) return e.toMap(); + return e.toString(); + }).toList(), + "count": count, + }; + } + + @override + String toString() => toMap().toString(); +} diff --git a/lib/services/exchange/exolix/api/helpers/parse_decimal.dart b/lib/services/exchange/exolix/api/helpers/parse_decimal.dart new file mode 100644 index 000000000..ba42835f7 --- /dev/null +++ b/lib/services/exchange/exolix/api/helpers/parse_decimal.dart @@ -0,0 +1,27 @@ +import 'package:decimal/decimal.dart'; + +Decimal parseDecimal(dynamic value) { + if (value is Decimal) return value; + if (value is int) return Decimal.fromInt(value); + if (value is double) { + final parsed = Decimal.tryParse(value.toString()); + if (parsed != null) return parsed; + throw FormatException( + "Could not convert double to Decimal", + value.toString(), + ); + } + if (value is String) { + final parsed = Decimal.tryParse(value); + if (parsed != null) return parsed; + throw FormatException( + "Expected a numeric Decimal value but got unparseable string", + value, + ); + } + throw FormatException( + "Expected a Decimal-compatible value (num or numeric String) but got" + " ${value.runtimeType}", + "$value", + ); +} diff --git a/lib/services/exchange/exolix/exolix_exchange.dart b/lib/services/exchange/exolix/exolix_exchange.dart new file mode 100644 index 000000000..d8c0f1a83 --- /dev/null +++ b/lib/services/exchange/exolix/exolix_exchange.dart @@ -0,0 +1,306 @@ +import 'package:decimal/decimal.dart'; +import 'package:uuid/uuid.dart'; + +import '../../../app_config.dart'; +import '../../../exceptions/exchange/exchange_exception.dart'; +import '../../../models/exchange/response_objects/estimate.dart'; +import '../../../models/exchange/response_objects/range.dart'; +import '../../../models/exchange/response_objects/trade.dart'; +import '../../../models/isar/exchange_cache/currency.dart'; +import '../exchange.dart'; +import '../exchange_response.dart'; +import 'api/dto/exolix_currency.dart'; +import 'api/exolix_api.dart'; + +class ExolixExchange extends Exchange { + ExolixExchange._(); + + static ExolixExchange? _instance; + static ExolixExchange get instance => _instance ??= ExolixExchange._(); + + static const exchangeName = "Exolix"; + + @override + String get name => exchangeName; + + @override + Future> createTrade({ + required String from, + required String to, + required String? fromNetwork, + required String? toNetwork, + required bool fixedRate, + required Decimal amount, + required String addressTo, + String? extraId, + required String addressRefund, + required String refundExtraId, + Estimate? estimate, + required bool reversed, + }) async { + try { + if (fromNetwork == null || toNetwork == null) { + throw ExchangeException("Exolix requires coin network args", .generic); + } + + final result = await ExolixApi.createTransaction( + coinFrom: from, + networkFrom: fromNetwork, + coinTo: to, + networkTo: toNetwork, + withdrawalAddress: addressTo, + amount: reversed ? null : amount, + withdrawalAmount: reversed ? amount : null, + withdrawalExtraId: extraId, + refundAddress: addressRefund, + refundExtraId: refundExtraId, + rateType: fixedRate ? .fixed : .float, + ); + + final trade = Trade( + uuid: const Uuid().v1(), + tradeId: result.id, + rateType: result.rateType == .float ? "estimated" : "fixed", + direction: reversed ? "reversed" : "normal", + timestamp: result.createdAt ?? DateTime.now(), + updatedAt: result.createdAt ?? DateTime.now(), + payInCurrency: result.coinFrom.coinCode, + payInAmount: result.amount.toString(), + payInAddress: result.depositAddress, + payInNetwork: result.coinFrom.network, + payInExtraId: result.depositExtraId ?? "", + payInTxid: result.hashIn.hash ?? "", + payOutCurrency: result.coinTo.coinCode, + payOutAmount: result.amountTo.toString(), + payOutAddress: result.withdrawalAddress, + payOutNetwork: result.coinTo.network, + payOutExtraId: result.withdrawalExtraId ?? "", + payOutTxid: result.hashOut.hash ?? "", + refundAddress: result.refundAddress ?? addressRefund, + refundExtraId: result.refundExtraId ?? refundExtraId, + status: result.status.name, + exchangeName: exchangeName, + ); + + return ExchangeResponse(value: trade); + } catch (e) { + return ExchangeResponse( + exception: ExchangeException( + e.toString(), + ExchangeExceptionType.generic, + ), + ); + } + } + + @override + Future>> getAllCurrencies( + bool fixedRate, + ) async { + try { + const pageSize = 100; // some reasonable value + final collected = []; + int page = 1; + + // First page gives us `count` so we know when to stop. + final first = await ExolixApi.getCurrencies( + page: page, + size: pageSize, + withNetworks: true, + ); + collected.addAll(first.data); + final total = first.count; + + while (collected.length < total && first.data.isNotEmpty) { + page += 1; + final next = await ExolixApi.getCurrencies( + page: page, + size: pageSize, + withNetworks: true, + ); + if (next.data.isEmpty) { + // Server says we're done even though count disagrees — stop rather + // than loop forever. + break; + } + collected.addAll(next.data); + } + + final results = []; + for (final currency in collected) { + for (final net in currency.networks) { + results.add( + Currency( + exchangeName: exchangeName, + ticker: currency.code, + name: net.isDefault + ? currency.name + : "${currency.name} (${net.shortName})", + network: net.network, + image: net.icon ?? currency.icon ?? "", + isFiat: false, + rateType: .both, + isStackCoin: AppConfig.isStackCoin(currency.code), + tokenContract: net.contract, + isAvailable: true, + ), + ); + } + } + + return ExchangeResponse(value: results); + } on ExchangeException catch (e) { + return ExchangeResponse(exception: e); + } catch (e) { + return ExchangeResponse( + exception: ExchangeException( + e.toString(), + ExchangeExceptionType.generic, + ), + ); + } + } + + @override + Future>> getEstimates( + String from, + String? fromNetwork, + String to, + String? toNetwork, + Decimal amount, + bool fixedRate, + bool reversed, + ) async { + try { + final response = await ExolixApi.getRate( + coinFrom: from, + coinTo: to, + networkFrom: fromNetwork, + networkTo: toNetwork, + amount: reversed ? null : amount, + withdrawalAmount: reversed ? amount : null, + rateType: fixedRate ? .fixed : .float, + ); + + final estimate = Estimate( + estimatedAmount: reversed ? response.fromAmount : response.toAmount, + fixedRate: fixedRate, + reversed: reversed, + exchangeProvider: exchangeName, + ); + + return ExchangeResponse(value: [estimate]); + } on ExchangeException catch (e) { + return ExchangeResponse(exception: e); + } catch (e) { + return ExchangeResponse( + exception: ExchangeException( + e.toString(), + ExchangeExceptionType.generic, + ), + ); + } + } + + @override + Future> getRange( + String from, + String? fromNetwork, + String to, + String? toNetwork, + bool fixedRate, + ) async { + try { + final response = await ExolixApi.getRate( + coinFrom: from, + coinTo: to, + networkFrom: fromNetwork, + networkTo: toNetwork, + amount: Decimal.one, // hack in a random value placeholder I guess? + rateType: fixedRate ? .fixed : .float, + ); + + return ExchangeResponse( + value: Range(min: response.minAmount, max: response.maxAmount), + ); + } on ExchangeException catch (e) { + return ExchangeResponse(exception: e); + } catch (e) { + return ExchangeResponse( + exception: ExchangeException( + e.toString(), + ExchangeExceptionType.generic, + ), + ); + } + } + + @override + Future> getTrade(String tradeId) async { + try { + throw UnimplementedError("Not currently used in this app"); + } catch (e) { + return ExchangeResponse( + exception: ExchangeException( + e.toString(), + ExchangeExceptionType.generic, + ), + ); + } + } + + @override + Future>> getTrades() async { + try { + throw UnimplementedError("Not currently used in this app"); + } catch (e) { + return ExchangeResponse>( + exception: ExchangeException( + e.toString(), + ExchangeExceptionType.generic, + ), + ); + } + } + + @override + Future> updateTrade(Trade trade) async { + try { + final result = await ExolixApi.getTransaction(id: trade.tradeId); + + return ExchangeResponse( + value: Trade( + uuid: trade.uuid, + tradeId: result.id, + rateType: result.rateType == .float ? "estimated" : "fixed", + direction: trade.direction, + timestamp: result.createdAt ?? DateTime.now(), + updatedAt: result.createdAt ?? DateTime.now(), + payInCurrency: result.coinFrom.coinCode, + payInAmount: result.amount.toString(), + payInAddress: result.depositAddress, + payInNetwork: result.coinFrom.network, + payInExtraId: result.depositExtraId ?? "", + payInTxid: result.hashIn.hash ?? "", + payOutCurrency: result.coinTo.coinCode, + payOutAmount: result.amountTo.toString(), + payOutAddress: result.withdrawalAddress, + payOutNetwork: result.coinTo.network, + payOutExtraId: result.withdrawalExtraId ?? "", + payOutTxid: result.hashOut.hash ?? "", + refundAddress: result.refundAddress ?? trade.refundAddress, + refundExtraId: result.refundExtraId ?? trade.refundExtraId, + status: result.status.name, + exchangeName: exchangeName, + ), + ); + } catch (e) { + return ExchangeResponse( + exception: ExchangeException( + e.toString(), + ExchangeExceptionType.generic, + ), + ); + } + } +} diff --git a/lib/services/exchange/trocador/trocador_exchange.dart b/lib/services/exchange/trocador/trocador_exchange.dart index ffb217a9f..b409387af 100644 --- a/lib/services/exchange/trocador/trocador_exchange.dart +++ b/lib/services/exchange/trocador/trocador_exchange.dart @@ -67,38 +67,37 @@ class TrocadorExchange extends Exchange { Estimate? estimate, required bool reversed, }) async { - final response = - reversed - ? await TrocadorAPI.createNewPaymentRateTrade( - isOnion: false, - rateId: estimate?.rateId, - fromTicker: from.toLowerCase(), - fromNetwork: onlySupportedNetwork, - toTicker: to.toLowerCase(), - toNetwork: onlySupportedNetwork, - toAmount: amount.toString(), - receivingAddress: addressTo, - receivingMemo: null, - refundAddress: addressRefund, - refundMemo: null, - exchangeProvider: estimate!.exchangeProvider!, - isFixedRate: fixedRate, - ) - : await TrocadorAPI.createNewStandardRateTrade( - isOnion: false, - rateId: estimate?.rateId, - fromTicker: from.toLowerCase(), - fromNetwork: onlySupportedNetwork, - toTicker: to.toLowerCase(), - toNetwork: onlySupportedNetwork, - fromAmount: amount.toString(), - receivingAddress: addressTo, - receivingMemo: null, - refundAddress: addressRefund, - refundMemo: null, - exchangeProvider: estimate!.exchangeProvider!, - isFixedRate: fixedRate, - ); + final response = reversed + ? await TrocadorAPI.createNewPaymentRateTrade( + isOnion: false, + rateId: estimate?.rateId, + fromTicker: from.toLowerCase(), + fromNetwork: onlySupportedNetwork, + toTicker: to.toLowerCase(), + toNetwork: onlySupportedNetwork, + toAmount: amount.toString(), + receivingAddress: addressTo, + receivingMemo: null, + refundAddress: addressRefund, + refundMemo: null, + exchangeProvider: estimate!.exchangeProvider!, + isFixedRate: fixedRate, + ) + : await TrocadorAPI.createNewStandardRateTrade( + isOnion: false, + rateId: estimate?.rateId, + fromTicker: from.toLowerCase(), + fromNetwork: onlySupportedNetwork, + toTicker: to.toLowerCase(), + toNetwork: onlySupportedNetwork, + fromAmount: amount.toString(), + receivingAddress: addressTo, + receivingMemo: null, + refundAddress: addressRefund, + refundMemo: null, + exchangeProvider: estimate!.exchangeProvider!, + isFixedRate: fixedRate, + ); if (response.value == null) { return ExchangeResponse(exception: response.exception); @@ -144,23 +143,22 @@ class TrocadorExchange extends Exchange { _cachedCurrencies?.removeWhere((e) => e.network != onlySupportedNetwork); - final value = - _cachedCurrencies - ?.map( - (e) => Currency( - exchangeName: exchangeName, - ticker: e.ticker, - name: e.name, - network: e.network, - image: e.image, - isFiat: false, - rateType: SupportedRateType.both, - isStackCoin: AppConfig.isStackCoin(e.ticker), - tokenContract: null, - isAvailable: true, - ), - ) - .toList(); + final value = _cachedCurrencies + ?.map( + (e) => Currency( + exchangeName: exchangeName, + ticker: e.ticker, + name: e.name, + network: e.network, + image: e.image, + isFiat: false, + rateType: SupportedRateType.both, + isStackCoin: AppConfig.isStackCoin(e.ticker), + tokenContract: null, + isAvailable: true, + ), + ) + .toList(); if (value == null) { return ExchangeResponse( @@ -222,24 +220,23 @@ class TrocadorExchange extends Exchange { bool fixedRate, bool reversed, ) async { - final response = - reversed - ? await TrocadorAPI.getNewPaymentRate( - isOnion: false, - fromTicker: from, - fromNetwork: onlySupportedNetwork, - toTicker: to, - toNetwork: onlySupportedNetwork, - toAmount: amount.toString(), - ) - : await TrocadorAPI.getNewStandardRate( - isOnion: false, - fromTicker: from, - fromNetwork: onlySupportedNetwork, - toTicker: to, - toNetwork: onlySupportedNetwork, - fromAmount: amount.toString(), - ); + final response = reversed + ? await TrocadorAPI.getNewPaymentRate( + isOnion: false, + fromTicker: from, + fromNetwork: onlySupportedNetwork, + toTicker: to, + toNetwork: onlySupportedNetwork, + toAmount: amount.toString(), + ) + : await TrocadorAPI.getNewStandardRate( + isOnion: false, + fromTicker: from, + fromNetwork: onlySupportedNetwork, + toTicker: to, + toNetwork: onlySupportedNetwork, + fromAmount: amount.toString(), + ); if (response.value == null) { return ExchangeResponse(exception: response.exception); @@ -249,8 +246,10 @@ class TrocadorExchange extends Exchange { final List cOrLowerQuotes = []; for (final quote in response.value!.quotes) { + final provider = quote.provider.toLowerCase(); if (quote.fixed == fixedRate && - quote.provider.toLowerCase() != "changenow") { + provider != "changenow" && + provider != "exolix") { final rating = quote.kycRating.toLowerCase(); if (rating == "a" || rating == "b") { estimates.add( @@ -288,9 +287,8 @@ class TrocadorExchange extends Exchange { } return ExchangeResponse( - value: - estimates - ..sort((a, b) => b.estimatedAmount.compareTo(a.estimatedAmount)), + value: estimates + ..sort((a, b) => b.estimatedAmount.compareTo(a.estimatedAmount)), ); } diff --git a/lib/themes/stack_colors.dart b/lib/themes/stack_colors.dart index 29804aa0c..ae82bf382 100644 --- a/lib/themes/stack_colors.dart +++ b/lib/themes/stack_colors.dart @@ -45,7 +45,7 @@ class StackColors extends ThemeExtension { final Color textError; final Color textRestore; -// button background + // button background final Color buttonBackPrimary; final Color buttonBackSecondary; final Color buttonBackPrimaryDisabled; @@ -58,7 +58,7 @@ class StackColors extends ThemeExtension { final Color numpadBackDefault; final Color bottomNavBack; -// button text/element + // button text/element final Color buttonTextPrimary; final Color buttonTextSecondary; final Color buttonTextPrimaryDisabled; @@ -73,17 +73,17 @@ class StackColors extends ThemeExtension { final Color customTextButtonEnabledText; final Color customTextButtonDisabledText; -// switch background + // switch background final Color switchBGOn; final Color switchBGOff; final Color switchBGDisabled; -// switch circle + // switch circle final Color switchCircleOn; final Color switchCircleOff; final Color switchCircleDisabled; -// step indicator background + // step indicator background final Color stepIndicatorBGCheck; final Color stepIndicatorBGNumber; final Color stepIndicatorBGInactive; @@ -93,7 +93,7 @@ class StackColors extends ThemeExtension { final Color stepIndicatorIconNumber; final Color stepIndicatorIconInactive; -// checkbox + // checkbox final Color checkboxBGChecked; final Color checkboxBorderEmpty; final Color checkboxBGDisabled; @@ -101,7 +101,7 @@ class StackColors extends ThemeExtension { final Color checkboxIconDisabled; final Color checkboxTextLabel; -// snack bar + // snack bar final Color snackBarBackSuccess; final Color snackBarBackError; final Color snackBarBackInfo; @@ -109,7 +109,7 @@ class StackColors extends ThemeExtension { final Color snackBarTextError; final Color snackBarTextInfo; -// icons + // icons final Color bottomNavIconBack; final Color bottomNavIconIcon; final Color bottomNavIconIconHighlighted; @@ -122,7 +122,7 @@ class StackColors extends ThemeExtension { final Color settingsIconBack2; final Color settingsIconElement; -// text field + // text field final Color textFieldActiveBG; final Color textFieldDefaultBG; final Color textFieldErrorBG; @@ -145,12 +145,12 @@ class StackColors extends ThemeExtension { final Color textFieldErrorSearchIconRight; final Color textFieldSuccessSearchIconRight; -// settings item level2 + // settings item level2 final Color settingsItem2ActiveBG; final Color settingsItem2ActiveText; final Color settingsItem2ActiveSub; -// radio buttons + // radio buttons final Color radioButtonIconBorder; final Color radioButtonIconBorderDisabled; final Color radioButtonBorderEnabled; @@ -162,19 +162,19 @@ class StackColors extends ThemeExtension { final Color radioButtonLabelEnabled; final Color radioButtonLabelDisabled; -// info text + // info text final Color infoItemBG; final Color infoItemLabel; final Color infoItemText; final Color infoItemIcons; -// popup + // popup final Color popupBG; -// currency list + // currency list final Color currencyListItemBG; -// bottom nav + // bottom nav final Color stackWalletBG; final Color stackWalletMid; final Color stackWalletBottom; @@ -192,7 +192,7 @@ class StackColors extends ThemeExtension { final Color textConfirmTotalAmount; final Color textSelectedWordTableItem; -// rate type toggle + // rate type toggle final Color rateTypeToggleColorOn; final Color rateTypeToggleColorOff; final Color rateTypeToggleDesktopColorOn; @@ -732,7 +732,8 @@ class StackColors extends ThemeExtension { buttonBackBorderDisabled ?? this.buttonBackBorderDisabled, buttonBackBorderSecondary: buttonBackBorderSecondary ?? this.buttonBackBorderSecondary, - buttonBackBorderSecondaryDisabled: buttonBackBorderSecondaryDisabled ?? + buttonBackBorderSecondaryDisabled: + buttonBackBorderSecondaryDisabled ?? this.buttonBackBorderSecondaryDisabled, numberBackDefault: numberBackDefault ?? this.numberBackDefault, numpadBackDefault: numpadBackDefault ?? this.numpadBackDefault, @@ -824,11 +825,13 @@ class StackColors extends ThemeExtension { textFieldSuccessLabel ?? this.textFieldSuccessLabel, textFieldActiveSearchIconRight: textFieldActiveSearchIconRight ?? this.textFieldActiveSearchIconRight, - textFieldDefaultSearchIconRight: textFieldDefaultSearchIconRight ?? + textFieldDefaultSearchIconRight: + textFieldDefaultSearchIconRight ?? this.textFieldDefaultSearchIconRight, textFieldErrorSearchIconRight: textFieldErrorSearchIconRight ?? this.textFieldErrorSearchIconRight, - textFieldSuccessSearchIconRight: textFieldSuccessSearchIconRight ?? + textFieldSuccessSearchIconRight: + textFieldSuccessSearchIconRight ?? this.textFieldSuccessSearchIconRight, settingsItem2ActiveBG: settingsItem2ActiveBG ?? this.settingsItem2ActiveBG, @@ -919,26 +922,14 @@ class StackColors extends ThemeExtension { gradientBackground: other.gradientBackground, homeViewButtonBarBoxShadow: other.homeViewButtonBarBoxShadow, standardBoxShadow: other.standardBoxShadow, - background: Color.lerp( - background, - other.background, - t, - )!, + background: Color.lerp(background, other.background, t)!, backgroundAppBar: Color.lerp( backgroundAppBar, other.backgroundAppBar, t, )!, - overlay: Color.lerp( - overlay, - other.overlay, - t, - )!, - accentColorBlue: Color.lerp( - accentColorBlue, - other.accentColorBlue, - t, - )!, + overlay: Color.lerp(overlay, other.overlay, t)!, + accentColorBlue: Color.lerp(accentColorBlue, other.accentColorBlue, t)!, accentColorGreen: Color.lerp( accentColorGreen, other.accentColorGreen, @@ -949,91 +940,31 @@ class StackColors extends ThemeExtension { other.accentColorYellow, t, )!, - accentColorRed: Color.lerp( - accentColorRed, - other.accentColorRed, - t, - )!, + accentColorRed: Color.lerp(accentColorRed, other.accentColorRed, t)!, accentColorOrange: Color.lerp( accentColorOrange, other.accentColorOrange, t, )!, - accentColorDark: Color.lerp( - accentColorDark, - other.accentColorDark, - t, - )!, - shadow: Color.lerp( - shadow, - other.shadow, - t, - )!, - textDark: Color.lerp( - textDark, - other.textDark, - t, - )!, - textDark2: Color.lerp( - textDark2, - other.textDark2, - t, - )!, - textDark3: Color.lerp( - textDark3, - other.textDark3, - t, - )!, - textSubtitle1: Color.lerp( - textSubtitle1, - other.textSubtitle1, - t, - )!, - textSubtitle2: Color.lerp( - textSubtitle2, - other.textSubtitle2, - t, - )!, - textSubtitle3: Color.lerp( - textSubtitle3, - other.textSubtitle3, - t, - )!, - textSubtitle4: Color.lerp( - textSubtitle4, - other.textSubtitle4, - t, - )!, - textSubtitle5: Color.lerp( - textSubtitle5, - other.textSubtitle5, - t, - )!, - textSubtitle6: Color.lerp( - textSubtitle6, - other.textSubtitle6, - t, - )!, - textWhite: Color.lerp( - textWhite, - other.textWhite, - t, - )!, + accentColorDark: Color.lerp(accentColorDark, other.accentColorDark, t)!, + shadow: Color.lerp(shadow, other.shadow, t)!, + textDark: Color.lerp(textDark, other.textDark, t)!, + textDark2: Color.lerp(textDark2, other.textDark2, t)!, + textDark3: Color.lerp(textDark3, other.textDark3, t)!, + textSubtitle1: Color.lerp(textSubtitle1, other.textSubtitle1, t)!, + textSubtitle2: Color.lerp(textSubtitle2, other.textSubtitle2, t)!, + textSubtitle3: Color.lerp(textSubtitle3, other.textSubtitle3, t)!, + textSubtitle4: Color.lerp(textSubtitle4, other.textSubtitle4, t)!, + textSubtitle5: Color.lerp(textSubtitle5, other.textSubtitle5, t)!, + textSubtitle6: Color.lerp(textSubtitle6, other.textSubtitle6, t)!, + textWhite: Color.lerp(textWhite, other.textWhite, t)!, textFavoriteCard: Color.lerp( textFavoriteCard, other.textFavoriteCard, t, )!, - textError: Color.lerp( - textError, - other.textError, - t, - )!, - textRestore: Color.lerp( - textRestore, - other.textRestore, - t, - )!, + textError: Color.lerp(textError, other.textError, t)!, + textRestore: Color.lerp(textRestore, other.textRestore, t)!, buttonBackPrimary: Color.lerp( buttonBackPrimary, other.buttonBackPrimary, @@ -1084,11 +1015,7 @@ class StackColors extends ThemeExtension { other.numpadBackDefault, t, )!, - bottomNavBack: Color.lerp( - bottomNavBack, - other.bottomNavBack, - t, - )!, + bottomNavBack: Color.lerp(bottomNavBack, other.bottomNavBack, t)!, buttonTextPrimary: Color.lerp( buttonTextPrimary, other.buttonTextPrimary, @@ -1139,11 +1066,7 @@ class StackColors extends ThemeExtension { other.numpadTextDefault, t, )!, - bottomNavText: Color.lerp( - bottomNavText, - other.bottomNavText, - t, - )!, + bottomNavText: Color.lerp(bottomNavText, other.bottomNavText, t)!, customTextButtonEnabledText: Color.lerp( customTextButtonEnabledText, other.customTextButtonEnabledText, @@ -1154,31 +1077,15 @@ class StackColors extends ThemeExtension { other.customTextButtonDisabledText, t, )!, - switchBGOn: Color.lerp( - switchBGOn, - other.switchBGOn, - t, - )!, - switchBGOff: Color.lerp( - switchBGOff, - other.switchBGOff, - t, - )!, + switchBGOn: Color.lerp(switchBGOn, other.switchBGOn, t)!, + switchBGOff: Color.lerp(switchBGOff, other.switchBGOff, t)!, switchBGDisabled: Color.lerp( switchBGDisabled, other.switchBGDisabled, t, )!, - switchCircleOn: Color.lerp( - switchCircleOn, - other.switchCircleOn, - t, - )!, - switchCircleOff: Color.lerp( - switchCircleOff, - other.switchCircleOff, - t, - )!, + switchCircleOn: Color.lerp(switchCircleOn, other.switchCircleOn, t)!, + switchCircleOff: Color.lerp(switchCircleOff, other.switchCircleOff, t)!, switchCircleDisabled: Color.lerp( switchCircleDisabled, other.switchCircleDisabled, @@ -1304,21 +1211,13 @@ class StackColors extends ThemeExtension { other.topNavIconPrimary, t, )!, - topNavIconGreen: Color.lerp( - topNavIconGreen, - other.topNavIconGreen, - t, - )!, + topNavIconGreen: Color.lerp(topNavIconGreen, other.topNavIconGreen, t)!, topNavIconYellow: Color.lerp( topNavIconYellow, other.topNavIconYellow, t, )!, - topNavIconRed: Color.lerp( - topNavIconRed, - other.topNavIconRed, - t, - )!, + topNavIconRed: Color.lerp(topNavIconRed, other.topNavIconRed, t)!, settingsIconBack: Color.lerp( settingsIconBack, other.settingsIconBack, @@ -1509,56 +1408,24 @@ class StackColors extends ThemeExtension { other.radioButtonLabelDisabled, t, )!, - infoItemBG: Color.lerp( - infoItemBG, - other.infoItemBG, - t, - )!, - infoItemLabel: Color.lerp( - infoItemLabel, - other.infoItemLabel, - t, - )!, - infoItemText: Color.lerp( - infoItemText, - other.infoItemText, - t, - )!, - infoItemIcons: Color.lerp( - infoItemIcons, - other.infoItemIcons, - t, - )!, - popupBG: Color.lerp( - popupBG, - other.popupBG, - t, - )!, + infoItemBG: Color.lerp(infoItemBG, other.infoItemBG, t)!, + infoItemLabel: Color.lerp(infoItemLabel, other.infoItemLabel, t)!, + infoItemText: Color.lerp(infoItemText, other.infoItemText, t)!, + infoItemIcons: Color.lerp(infoItemIcons, other.infoItemIcons, t)!, + popupBG: Color.lerp(popupBG, other.popupBG, t)!, currencyListItemBG: Color.lerp( currencyListItemBG, other.currencyListItemBG, t, )!, - stackWalletBG: Color.lerp( - stackWalletBG, - other.stackWalletBG, - t, - )!, - stackWalletMid: Color.lerp( - stackWalletMid, - other.stackWalletMid, - t, - )!, + stackWalletBG: Color.lerp(stackWalletBG, other.stackWalletBG, t)!, + stackWalletMid: Color.lerp(stackWalletMid, other.stackWalletMid, t)!, stackWalletBottom: Color.lerp( stackWalletBottom, other.stackWalletBottom, t, )!, - bottomNavShadow: Color.lerp( - bottomNavShadow, - other.bottomNavShadow, - t, - )!, + bottomNavShadow: Color.lerp(bottomNavShadow, other.bottomNavShadow, t)!, favoriteStarActive: Color.lerp( favoriteStarActive, other.favoriteStarActive, @@ -1569,16 +1436,8 @@ class StackColors extends ThemeExtension { other.favoriteStarInactive, t, )!, - splash: Color.lerp( - splash, - other.splash, - t, - )!, - highlight: Color.lerp( - highlight, - other.highlight, - t, - )!, + splash: Color.lerp(splash, other.splash, t)!, + highlight: Color.lerp(highlight, other.highlight, t)!, warningForeground: Color.lerp( warningForeground, other.warningForeground, @@ -1629,26 +1488,14 @@ class StackColors extends ThemeExtension { other.rateTypeToggleDesktopColorOff, t, )!, - ethTagText: Color.lerp( - ethTagText, - other.ethTagText, - t, - )!, - ethTagBG: Color.lerp( - ethTagBG, - other.ethTagBG, - t, - )!, + ethTagText: Color.lerp(ethTagText, other.ethTagText, t)!, + ethTagBG: Color.lerp(ethTagBG, other.ethTagBG, t)!, ethWalletTagText: Color.lerp( ethWalletTagText, other.ethWalletTagText, t, )!, - ethWalletTagBG: Color.lerp( - ethWalletTagBG, - other.ethWalletTagBG, - t, - )!, + ethWalletTagBG: Color.lerp(ethWalletTagBG, other.ethWalletTagBG, t)!, tokenSummaryTextPrimary: Color.lerp( tokenSummaryTextPrimary, other.tokenSummaryTextPrimary, @@ -1659,11 +1506,7 @@ class StackColors extends ThemeExtension { other.tokenSummaryTextSecondary, t, )!, - tokenSummaryBG: Color.lerp( - tokenSummaryBG, - other.tokenSummaryBG, - t, - )!, + tokenSummaryBG: Color.lerp(tokenSummaryBG, other.tokenSummaryBG, t)!, tokenSummaryButtonBG: Color.lerp( tokenSummaryButtonBG, other.tokenSummaryButtonBG, @@ -1695,14 +1538,17 @@ class StackColors extends ThemeExtension { case "Finished": case "finished": case "Completed": + case "success": return accentColorGreen; case "Failed": case "failed": case "closed": case "expired": + case "overdue": return accentColorRed; case "Refunded": case "refunded": + case "refund": return textSubtitle2; default: return const Color(0xFFD3A90F); @@ -1711,125 +1557,95 @@ class StackColors extends ThemeExtension { ButtonStyle? getDeleteEnabledButtonStyle(BuildContext context) => Theme.of(context).textButtonTheme.style?.copyWith( - backgroundColor: MaterialStateProperty.all( - textFieldErrorBG, - ), - ); + backgroundColor: MaterialStateProperty.all(textFieldErrorBG), + ); ButtonStyle? getDeleteDisabledButtonStyle(BuildContext context) => Theme.of(context).textButtonTheme.style?.copyWith( - backgroundColor: MaterialStateProperty.all( - buttonBackSecondaryDisabled, - ), - ); + backgroundColor: MaterialStateProperty.all( + buttonBackSecondaryDisabled, + ), + ); ButtonStyle? getPrimaryEnabledButtonStyle(BuildContext context) => Theme.of(context).textButtonTheme.style?.copyWith( - backgroundColor: MaterialStateProperty.all( - buttonBackPrimary, - ), - ); + backgroundColor: MaterialStateProperty.all(buttonBackPrimary), + ); ButtonStyle? getPrimaryDisabledButtonStyle(BuildContext context) => Theme.of(context).textButtonTheme.style?.copyWith( - backgroundColor: MaterialStateProperty.all( - buttonBackPrimaryDisabled, - ), - ); + backgroundColor: MaterialStateProperty.all( + buttonBackPrimaryDisabled, + ), + ); ButtonStyle? getOutlineBlueButtonStyle(BuildContext context) => Theme.of(context).textButtonTheme.style?.copyWith( - backgroundColor: MaterialStateProperty.all( - Colors.transparent, - ), - side: MaterialStateProperty.all( - BorderSide( - color: customTextButtonEnabledText, - ), - ), - ); + backgroundColor: MaterialStateProperty.all(Colors.transparent), + side: MaterialStateProperty.all( + BorderSide(color: customTextButtonEnabledText), + ), + ); ButtonStyle? getOutlineBlueButtonDisabledStyle(BuildContext context) => Theme.of(context).textButtonTheme.style?.copyWith( - backgroundColor: MaterialStateProperty.all( - Colors.transparent, - ), - side: MaterialStateProperty.all( - BorderSide( - color: customTextButtonDisabledText, - ), - ), - ); + backgroundColor: MaterialStateProperty.all(Colors.transparent), + side: MaterialStateProperty.all( + BorderSide(color: customTextButtonDisabledText), + ), + ); ButtonStyle? getSecondaryEnabledButtonStyle(BuildContext context) => Theme.of(context).textButtonTheme.style?.copyWith( - backgroundColor: MaterialStateProperty.all( - buttonBackSecondary, - ), - shape: MaterialStateProperty.all( - RoundedRectangleBorder( - side: BorderSide( - color: buttonBackBorderSecondary, - width: 1, - ), - borderRadius: BorderRadius.circular(10000), - ), - ), - ); + backgroundColor: MaterialStateProperty.all(buttonBackSecondary), + shape: MaterialStateProperty.all( + RoundedRectangleBorder( + side: BorderSide(color: buttonBackBorderSecondary, width: 1), + borderRadius: BorderRadius.circular(10000), + ), + ), + ); ButtonStyle? getSecondaryDisabledButtonStyle(BuildContext context) => Theme.of(context).textButtonTheme.style?.copyWith( - backgroundColor: MaterialStateProperty.all( - buttonBackSecondaryDisabled, - ), - shape: MaterialStateProperty.all( - RoundedRectangleBorder( - side: BorderSide( - color: buttonBackBorderSecondaryDisabled, - width: 1, - ), - borderRadius: BorderRadius.circular(10000), - ), + backgroundColor: MaterialStateProperty.all( + buttonBackSecondaryDisabled, + ), + shape: MaterialStateProperty.all( + RoundedRectangleBorder( + side: BorderSide( + color: buttonBackBorderSecondaryDisabled, + width: 1, ), - ); + borderRadius: BorderRadius.circular(10000), + ), + ), + ); ButtonStyle? getSmallSecondaryEnabledButtonStyle(BuildContext context) => Theme.of(context).textButtonTheme.style?.copyWith( - backgroundColor: MaterialStateProperty.all( - textFieldDefaultBG, - ), - shape: MaterialStateProperty.all( - RoundedRectangleBorder( - side: BorderSide( - color: buttonBackBorderSecondary, - width: 1, - ), - borderRadius: BorderRadius.circular(10000), - ), - ), - ); + backgroundColor: MaterialStateProperty.all(textFieldDefaultBG), + shape: MaterialStateProperty.all( + RoundedRectangleBorder( + side: BorderSide(color: buttonBackBorderSecondary, width: 1), + borderRadius: BorderRadius.circular(10000), + ), + ), + ); ButtonStyle? getDesktopMenuButtonStyle(BuildContext context) => Theme.of(context).textButtonTheme.style?.copyWith( - backgroundColor: MaterialStateProperty.all( - popupBG, - ), - ); + backgroundColor: MaterialStateProperty.all(popupBG), + ); ButtonStyle? getDesktopMenuButtonStyleSelected(BuildContext context) => Theme.of(context).textButtonTheme.style?.copyWith( - backgroundColor: MaterialStateProperty.all( - textFieldDefaultBG, - ), - ); + backgroundColor: MaterialStateProperty.all(textFieldDefaultBG), + ); ButtonStyle? getDesktopSettingsButtonStyle(BuildContext context) => Theme.of(context).textButtonTheme.style?.copyWith( - backgroundColor: MaterialStateProperty.all( - background, - ), - overlayColor: MaterialStateProperty.all( - Colors.transparent, - ), - ); + backgroundColor: MaterialStateProperty.all(background), + overlayColor: MaterialStateProperty.all(Colors.transparent), + ); } diff --git a/lib/utilities/assets.dart b/lib/utilities/assets.dart index eebe60e09..9d322d385 100644 --- a/lib/utilities/assets.dart +++ b/lib/utilities/assets.dart @@ -11,6 +11,7 @@ import 'package:flutter/material.dart'; import '../services/exchange/change_now/change_now_exchange.dart'; +import '../services/exchange/exolix/exolix_exchange.dart'; import '../services/exchange/nanswap/nanswap_exchange.dart'; import '../services/exchange/simpleswap/simpleswap_exchange.dart'; import '../services/exchange/trocador/trocador_exchange.dart'; @@ -50,6 +51,8 @@ class _EXCHANGE { String get nanswap => "${_path}nanswap.svg"; String get wizard => "${_path}wizard.svg"; + String get exolix => "${_path}exolix.png"; + String getIconFor({required String exchangeName}) { switch (exchangeName) { case SimpleSwapExchange.exchangeName: @@ -64,6 +67,8 @@ class _EXCHANGE { return nanswap; case WizardSwapExchange.exchangeName: return wizard; + case ExolixExchange.exchangeName: + return exolix; default: throw ArgumentError( "Invalid exchange name passed to " diff --git a/lib/utilities/util.dart b/lib/utilities/util.dart index a89f039f9..c722832ef 100644 --- a/lib/utilities/util.dart +++ b/lib/utilities/util.dart @@ -91,7 +91,7 @@ abstract class Util { final pretty = encoder.convert(json); result = pretty; } else { - result = dynamic.toString(); + result = json.toString(); } if (debugTitle != null) { diff --git a/lib/widgets/icon_widgets/exchange_icon.dart b/lib/widgets/icon_widgets/exchange_icon.dart new file mode 100644 index 000000000..d9ceacd44 --- /dev/null +++ b/lib/widgets/icon_widgets/exchange_icon.dart @@ -0,0 +1,34 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_svg/svg.dart'; + +import '../../services/exchange/exchange.dart'; +import '../../utilities/assets.dart'; +import '../../utilities/util.dart'; + +class ExchangeIcon extends StatelessWidget { + const ExchangeIcon({super.key, required this.exchange}); + + final Exchange exchange; + + @override + Widget build(BuildContext context) { + final isDesktop = Util.isDesktop; + final asset = Assets.exchange + .getIconFor(exchangeName: exchange.name) + .toLowerCase(); + + if (asset.endsWith(".svg")) { + return SvgPicture.asset( + asset, + width: isDesktop ? 32 : 24, + height: isDesktop ? 32 : 24, + ); + } else { + return Image.asset( + asset, + width: isDesktop ? 32 : 24, + height: isDesktop ? 32 : 24, + ); + } + } +} diff --git a/scripts/prebuild.sh b/scripts/prebuild.sh index 0aa13ea22..6aeaca63b 100755 --- a/scripts/prebuild.sh +++ b/scripts/prebuild.sh @@ -4,7 +4,7 @@ KEYS=../lib/external_api_keys.dart if ! test -f "$KEYS"; then echo 'prebuild.sh: creating template lib/external_api_keys.dart file' - printf 'const kChangeNowApiKey = "";\nconst kSimpleSwapApiKey = "";\nconst kNanswapApiKey = "";\nconst kNanoSwapRpcApiKey = "";\nconst kWizSwapApiKey = "";\nconst kShopInBitAccessKey = "";\nconst kShopInBitPartnerSecret = "";\nconst kCakePayApiToken = "";\n' > $KEYS + printf 'const kChangeNowApiKey = "";\nconst kSimpleSwapApiKey = "";\nconst kNanswapApiKey = "";\nconst kNanoSwapRpcApiKey = "";\nconst kWizSwapApiKey = "";\nconst kShopInBitAccessKey = "";\nconst kShopInBitPartnerSecret = "";\nconst kCakePayApiToken = "";\nconst kExolixApiKey = "";\n' > $KEYS fi # Create template wallet test parameter files if they don't already exist