diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 53b0a10648..77b62bd5d8 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -49,18 +49,6 @@ jobs: run: | if [ -n "$CHANGE_NOW" ]; then echo "$CHANGE_NOW" | base64 --decode > lib/external_api_keys.dart - else - cat > lib/external_api_keys.dart << 'EOF' - const String kChangeNowApiKey = ""; - const String kSimpleSwapApiKey = ""; - const String kNanswapApiKey = ""; - const String kNanoSwapRpcApiKey = ""; - const String kWizSwapApiKey = ""; - const kShopInBitAccessKey = ""; - const kShopInBitPartnerSecret = ""; - const kCakePayApiToken = ""; - const kExolixApiKey = ""; - EOF fi - name: Ensure app config for tests diff --git a/assets/images/rsfiro.svg b/assets/images/rsfiro.svg new file mode 100644 index 0000000000..03d755176a --- /dev/null +++ b/assets/images/rsfiro.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/lib/app_config.dart b/lib/app_config.dart index 4e926ad8ea..7e0b736744 100644 --- a/lib/app_config.dart +++ b/lib/app_config.dart @@ -1,12 +1,23 @@ // ignore: unused_import import 'dart:io'; +import 'models/isar/models/ethereum/eth_contract.dart'; +import 'utilities/default_eth_tokens.dart'; import 'wallets/crypto_currency/crypto_currency.dart'; import 'wallets/crypto_currency/intermediate/frost_currency.dart'; part 'app_config.g.dart'; -enum AppFeature { themeSelection, buy, swap, tor, shopinBit, cakePay } +enum AppFeature { + themeSelection, + buy, + swap, + tor, + shopinBit, + cakePay, + addCustomTokens, + limitedEthTokenDefaults, +} abstract class AppConfig { static const appName = _prefix + _separator + suffix; @@ -28,6 +39,13 @@ abstract class AppConfig { static List get coins => _supportedCoins; + static List get defaultEthTokens { + if (coins.whereType().isEmpty) return const []; + return hasFeature(AppFeature.limitedEthTokenDefaults) + ? DefaultTokens.limited + : DefaultTokens.list; + } + static ({String from, String fromFuzzyNet, String to, String toFuzzyNet}) get swapDefaults => _swapDefaults; diff --git a/lib/pages/add_wallet_views/add_token_view/edit_wallet_tokens_view.dart b/lib/pages/add_wallet_views/add_token_view/edit_wallet_tokens_view.dart index d81afe51e5..0a5b76caef 100644 --- a/lib/pages/add_wallet_views/add_token_view/edit_wallet_tokens_view.dart +++ b/lib/pages/add_wallet_views/add_token_view/edit_wallet_tokens_view.dart @@ -15,6 +15,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/svg.dart'; import 'package:isar_community/isar.dart'; +import '../../../app_config.dart'; import '../../../db/isar/main_db.dart'; import '../../../models/isar/models/ethereum/eth_contract.dart'; import '../../../models/isar/models/solana/sol_contract.dart'; @@ -80,6 +81,9 @@ class _EditWalletTokensViewState extends ConsumerState { final List tokenEntities = []; final bool isDesktop = Util.isDesktop; + final bool canAddCustomTokens = AppConfig.hasFeature( + AppFeature.addCustomTokens, + ); List filter( String text, @@ -100,11 +104,10 @@ class _EditWalletTokensViewState extends ConsumerState { } Future onNextPressed() async { - final selectedTokens = - tokenEntities - .where((e) => e.selected) - .map((e) => e.token.address) - .toList(); + final selectedTokens = tokenEntities + .where((e) => e.selected) + .map((e) => e.token.address) + .toList(); final wallet = ref.read(pWallets).getWallet(widget.walletId); @@ -119,13 +122,11 @@ class _EditWalletTokensViewState extends ConsumerState { Navigator.of(context).pop(42); } else { if (isDesktop) { - Navigator.of( - context, - ).popUntil(ModalRoute.withName(DesktopHomeView.routeName)); + Navigator.of(context) + .popUntil(ModalRoute.withName(DesktopHomeView.routeName)); } else { - await Navigator.of( - context, - ).pushNamedAndRemoveUntil(HomeView.routeName, (route) => false); + await Navigator.of(context) + .pushNamedAndRemoveUntil(HomeView.routeName, (route) => false); } if (mounted) { unawaited( @@ -160,9 +161,8 @@ class _EditWalletTokensViewState extends ConsumerState { ), ); } else { - final result = await Navigator.of( - context, - ).pushNamed(AddCustomTokenView.routeName); + final result = await Navigator.of(context) + .pushNamed(AddCustomTokenView.routeName); contract = result as EthContract?; } @@ -177,7 +177,9 @@ class _EditWalletTokensViewState extends ConsumerState { tokenEntities.add( AddTokenListElementData(contract!)..selected = true, ); - tokenEntities.sort((a, b) => a.token.name.compareTo(b.token.name)); + tokenEntities.sort( + (a, b) => a.token.name.compareTo(b.token.name), + ); } }); } @@ -199,9 +201,7 @@ class _EditWalletTokensViewState extends ConsumerState { ), ); } else { - final result = await Navigator.of( - context, - ).pushNamed( + final result = await Navigator.of(context).pushNamed( AddCustomSolanaTokenView.routeName, arguments: widget.walletId, ); @@ -228,9 +228,7 @@ class _EditWalletTokensViewState extends ConsumerState { if (tokenEntities .where((e) => e.token.address == token!.address) .isEmpty) { - tokenEntities.add( - AddTokenListElementData(token!)..selected = true, - ); + tokenEntities.add(AddTokenListElementData(token!)..selected = true); tokenEntities.sort((a, b) => a.token.name.compareTo(b.token.name)); } }); @@ -267,8 +265,7 @@ class _EditWalletTokensViewState extends ConsumerState { .sortByName() .findAllSync(); - if (contracts.isEmpty) { - contracts.addAll(DefaultTokens.list); + if (DefaultTokens.seedIfEmpty(contracts, AppConfig.defaultEthTokens)) { MainDB.instance .putEthContracts(contracts) .then( @@ -318,7 +315,8 @@ class _EditWalletTokensViewState extends ConsumerState { walletName, style: STextStyles.desktopSubtitleH2(context), ), - trailing: widget.contractsToMarkSelected == null + trailing: + widget.contractsToMarkSelected == null && canAddCustomTokens ? Padding( padding: const EdgeInsets.only(right: 24), child: SizedBox( @@ -410,14 +408,15 @@ class _EditWalletTokensViewState extends ConsumerState { padding: const EdgeInsets.symmetric(horizontal: 32), child: Row( children: [ - Expanded( - child: SecondaryButton( - label: "Add custom token", - buttonHeight: ButtonHeight.l, - onPressed: _addToken, + if (canAddCustomTokens) + Expanded( + child: SecondaryButton( + label: "Add custom token", + buttonHeight: ButtonHeight.l, + onPressed: _addToken, + ), ), - ), - const SizedBox(width: 16), + if (canAddCustomTokens) const SizedBox(width: 16), Expanded( child: PrimaryButton( label: "Done", @@ -448,9 +447,8 @@ class _EditWalletTokensViewState extends ConsumerState { _searchTerm = value; }); }, - style: STextStyles.desktopTextMedium( - context, - ).copyWith(height: 2), + style: STextStyles.desktopTextMedium(context) + .copyWith(height: 2), decoration: standardInputDecoration( "Search", @@ -505,7 +503,9 @@ class _EditWalletTokensViewState extends ConsumerState { child: AddTokenList( walletId: widget.walletId, items: filter(_searchTerm, tokenEntities), - addFunction: isDesktop ? null : _addToken, + addFunction: isDesktop || !canAddCustomTokens + ? null + : _addToken, ), ), const SizedBox(height: 12), @@ -516,9 +516,9 @@ class _EditWalletTokensViewState extends ConsumerState { } else { return Background( child: Scaffold( - backgroundColor: Theme.of( - context, - ).extension()!.background, + backgroundColor: Theme.of(context) + .extension()! + .background, appBar: AppBar( leading: AppBarBackButton( onPressed: () { @@ -526,28 +526,33 @@ class _EditWalletTokensViewState extends ConsumerState { }, ), actions: [ - Padding( - padding: const EdgeInsets.only(top: 10, bottom: 10, right: 20), - child: AspectRatio( - aspectRatio: 1, - child: AppBarIconButton( - size: 36, - shadows: const [], - color: Theme.of( - context, - ).extension()!.background, - icon: SvgPicture.asset( - Assets.svg.circlePlusFilled, - color: Theme.of( - context, - ).extension()!.topNavIconPrimary, - width: 20, - height: 20, + if (canAddCustomTokens) + Padding( + padding: const EdgeInsets.only( + top: 10, + bottom: 10, + right: 20, + ), + child: AspectRatio( + aspectRatio: 1, + child: AppBarIconButton( + size: 36, + shadows: const [], + color: Theme.of(context) + .extension()! + .background, + icon: SvgPicture.asset( + Assets.svg.circlePlusFilled, + color: Theme.of(context) + .extension()! + .topNavIconPrimary, + width: 20, + height: 20, + ), + onPressed: _addToken, ), - onPressed: _addToken, ), ), - ), ], ), body: SafeArea( @@ -620,7 +625,7 @@ class _EditWalletTokensViewState extends ConsumerState { child: AddTokenList( walletId: widget.walletId, items: filter(_searchTerm, tokenEntities), - addFunction: _addToken, + addFunction: canAddCustomTokens ? _addToken : null, ), ), const SizedBox(height: 16), diff --git a/lib/pages/add_wallet_views/add_token_view/sub_widgets/add_token_list_element.dart b/lib/pages/add_wallet_views/add_token_view/sub_widgets/add_token_list_element.dart index eecf914d73..82a692b64f 100644 --- a/lib/pages/add_wallet_views/add_token_view/sub_widgets/add_token_list_element.dart +++ b/lib/pages/add_wallet_views/add_token_view/sub_widgets/add_token_list_element.dart @@ -9,22 +9,16 @@ */ import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_svg/flutter_svg.dart'; -import 'package:isar_community/isar.dart'; -import '../../../../models/isar/exchange_cache/currency.dart'; import '../../../../models/isar/models/contract.dart'; import '../../../../models/isar/models/ethereum/eth_contract.dart'; -import '../../../../services/exchange/change_now/change_now_exchange.dart'; -import '../../../../services/exchange/exchange_data_loading_service.dart'; import '../../../../themes/stack_colors.dart'; -import '../../../../utilities/assets.dart'; import '../../../../utilities/text_styles.dart'; import '../../../../utilities/util.dart'; -import '../../../../widgets/app_icon.dart'; import '../../../../widgets/conditional_parent.dart'; import '../../../../widgets/custom_buttons/draggable_switch_button.dart'; +import '../../../../widgets/icon_widgets/eth_token_icon.dart'; +import '../../../../widgets/icon_widgets/sol_token_icon.dart'; import '../../../../widgets/rounded_white_container.dart'; class AddTokenListElementData { @@ -34,51 +28,18 @@ class AddTokenListElementData { bool selected = false; } -class AddTokenListElement extends ConsumerStatefulWidget { +class AddTokenListElement extends StatefulWidget { const AddTokenListElement({super.key, required this.data}); final AddTokenListElementData data; @override - ConsumerState createState() => - _AddTokenListElementState(); + State createState() => _AddTokenListElementState(); } -class _AddTokenListElementState extends ConsumerState { +class _AddTokenListElementState extends State { final bool isDesktop = Util.isDesktop; - Currency? currency; - - @override - void initState() { - super.initState(); - - ExchangeDataLoadingService.instance.isar.then((isar) async { - final currency = - await isar.currencies - .where() - .exchangeNameEqualTo(ChangeNowExchange.exchangeName) - .filter() - .tokenContractEqualTo( - widget.data.token.address, - caseSensitive: false, - ) - .and() - .imageIsNotEmpty() - .findFirst(); - - if (mounted) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) { - setState(() { - this.currency = currency; - }); - } - }); - } - }); - } - @override Widget build(BuildContext context) { final String mainLabel = widget.data.token.name; @@ -86,50 +47,45 @@ class _AddTokenListElementState extends ConsumerState { return RoundedWhiteContainer( padding: EdgeInsets.all(isDesktop ? 16 : 12), - borderColor: - isDesktop - ? Theme.of(context).extension()!.backgroundAppBar - : null, + borderColor: isDesktop + ? Theme.of(context).extension()!.backgroundAppBar + : null, child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Row( children: [ - currency != null - ? SvgPicture.network( - currency!.image, - width: iconSize, - height: iconSize, - placeholderBuilder: - (_) => AppIcon(width: iconSize, height: iconSize), - ) - : AppIcon(width: iconSize, height: iconSize), + widget.data.token is EthContract + ? EthTokenIcon( + contractAddress: widget.data.token.address, + size: iconSize, + ) + : SolTokenIcon( + mintAddress: widget.data.token.address, + size: iconSize, + ), const SizedBox(width: 12), ConditionalParent( condition: isDesktop, - builder: - (child) => Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - child, - const SizedBox(height: 2), - Text( - widget.data.token.symbol, - style: STextStyles.desktopTextExtraExtraSmall( - context, - ), - overflow: TextOverflow.ellipsis, - ), - ], + builder: (child) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + child, + const SizedBox(height: 2), + Text( + widget.data.token.symbol, + style: STextStyles.desktopTextExtraExtraSmall(context), + overflow: TextOverflow.ellipsis, ), + ], + ), child: Text( isDesktop ? mainLabel : "$mainLabel (${widget.data.token.symbol})", - style: - isDesktop - ? STextStyles.desktopTextSmall(context) - : STextStyles.w600_14(context), + style: isDesktop + ? STextStyles.desktopTextSmall(context) + : STextStyles.w600_14(context), overflow: TextOverflow.ellipsis, ), ), @@ -138,21 +94,20 @@ class _AddTokenListElementState extends ConsumerState { const SizedBox(width: 4), isDesktop ? Checkbox( - value: widget.data.selected, - onChanged: - (newValue) => - setState(() => widget.data.selected = newValue!), - ) + value: widget.data.selected, + onChanged: (newValue) => + setState(() => widget.data.selected = newValue!), + ) : SizedBox( - height: 20, - width: 40, - child: DraggableSwitchButton( - isOn: widget.data.selected, - onValueChanged: (newValue) { - widget.data.selected = newValue; - }, + height: 20, + width: 40, + child: DraggableSwitchButton( + isOn: widget.data.selected, + onValueChanged: (newValue) { + widget.data.selected = newValue; + }, + ), ), - ), ], ), ); diff --git a/lib/pages/add_wallet_views/add_wallet_view/add_wallet_view.dart b/lib/pages/add_wallet_views/add_wallet_view/add_wallet_view.dart index 13cb7a022e..8b2d18c0d8 100644 --- a/lib/pages/add_wallet_views/add_wallet_view/add_wallet_view.dart +++ b/lib/pages/add_wallet_views/add_wallet_view/add_wallet_view.dart @@ -103,17 +103,15 @@ class _AddWalletViewState extends ConsumerState { if (isDesktop) { contract = await showDialog( context: context, - builder: - (context) => const DesktopDialog( - maxWidth: 580, - maxHeight: 500, - child: AddCustomTokenView(), - ), + builder: (context) => const DesktopDialog( + maxWidth: 580, + maxHeight: 500, + child: AddCustomTokenView(), + ), ); } else { - contract = await Navigator.of( - context, - ).pushNamed(AddCustomTokenView.routeName); + contract = await Navigator.of(context) + .pushNamed(AddCustomTokenView.routeName); } if (contract != null) { @@ -137,17 +135,15 @@ class _AddWalletViewState extends ConsumerState { if (isDesktop) { token = await showDialog( context: context, - builder: - (context) => const DesktopDialog( - maxWidth: 580, - maxHeight: 500, - child: AddCustomSolanaTokenView(), - ), + builder: (context) => const DesktopDialog( + maxWidth: 580, + maxHeight: 500, + child: AddCustomSolanaTokenView(), + ), ); } else { - token = await Navigator.of( - context, - ).pushNamed(AddCustomSolanaTokenView.routeName); + token = await Navigator.of(context) + .pushNamed(AddCustomSolanaTokenView.routeName); } if (token != null) { @@ -176,11 +172,12 @@ class _AddWalletViewState extends ConsumerState { } if (AppConfig.coins.whereType().isNotEmpty) { - final contracts = - MainDB.instance.getEthContracts().sortByName().findAllSync(); + final contracts = MainDB.instance + .getEthContracts() + .sortByName() + .findAllSync(); - if (contracts.isEmpty) { - contracts.addAll(DefaultTokens.list); + if (DefaultTokens.seedIfEmpty(contracts, AppConfig.defaultEthTokens)) { MainDB.instance .putEthContracts(contracts) .then( @@ -274,60 +271,60 @@ class _AddWalletViewState extends ConsumerState { _searchTerm = value; }); }, - style: STextStyles.desktopTextMedium( - context, - ).copyWith(height: 2), - decoration: standardInputDecoration( - "Search", - _searchFocusNode, - context, - ).copyWith( - contentPadding: const EdgeInsets.symmetric( - vertical: 10, - ), - prefixIcon: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 16, - // vertical: 20, - ), - child: SvgPicture.asset( - Assets.svg.search, - width: 24, - height: 24, - color: - Theme.of(context) + style: STextStyles.desktopTextMedium(context) + .copyWith(height: 2), + decoration: + standardInputDecoration( + "Search", + _searchFocusNode, + context, + ).copyWith( + contentPadding: const EdgeInsets.symmetric( + vertical: 10, + ), + prefixIcon: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + // vertical: 20, + ), + child: SvgPicture.asset( + Assets.svg.search, + width: 24, + height: 24, + color: Theme.of(context) .extension()! .textFieldDefaultSearchIconLeft, - ), - ), - suffixIcon: - _searchFieldController.text.isNotEmpty + ), + ), + suffixIcon: + _searchFieldController.text.isNotEmpty ? Padding( - padding: const EdgeInsets.only( - right: 10, - ), - child: UnconstrainedBox( - child: Row( - children: [ - TextFieldIconButton( - child: const XIcon( - width: 24, - height: 24, + padding: const EdgeInsets.only( + right: 10, + ), + child: UnconstrainedBox( + child: Row( + children: [ + TextFieldIconButton( + child: const XIcon( + width: 24, + height: 24, + ), + onTap: () async { + setState(() { + _searchFieldController + .text = + ""; + _searchTerm = ""; + }); + }, ), - onTap: () async { - setState(() { - _searchFieldController - .text = ""; - _searchTerm = ""; - }); - }, - ), - ], + ], + ), ), - ), - ) + ) : null, - ), + ), ), ), ), @@ -358,19 +355,32 @@ class _AddWalletViewState extends ConsumerState { entities: filter(_searchTerm, tokenEntities), initialState: ExpandableState.expanded, animationDurationMultiplier: 0.5, - trailing: AddCustomTokenSelector( - addFunction: _addToken, - ), + trailing: + AppConfig.hasFeature( + AppFeature.addCustomTokens, + ) + ? AddCustomTokenSelector( + addFunction: _addToken, + ) + : null, ), if (solTokenEntities.isNotEmpty) ExpandingSubListItem( title: "Solana tokens", - entities: filter(_searchTerm, solTokenEntities), + entities: filter( + _searchTerm, + solTokenEntities, + ), initialState: ExpandableState.expanded, animationDurationMultiplier: 0.5, - trailing: AddCustomTokenSelector( - addFunction: _addSolToken, - ), + trailing: + AppConfig.hasFeature( + AppFeature.addCustomTokens, + ) + ? AddCustomTokenSelector( + addFunction: _addSolToken, + ) + : null, ), ], ), @@ -395,8 +405,9 @@ class _AddWalletViewState extends ConsumerState { } else { return Background( child: Scaffold( - backgroundColor: - Theme.of(context).extension()!.background, + backgroundColor: Theme.of(context) + .extension()! + .background, appBar: AppBar( leading: AppBarBackButton( onPressed: () { @@ -419,8 +430,7 @@ class _AddWalletViewState extends ConsumerState { Constants.size.circularBorderRadius, ), child: Semantics( - label: - "Search Text Field. Inputs Text To Search In Wallets.", + label: "Search Text Field. Inputs Text To Search In Wallets.", excludeSemantics: true, child: TextField( autofocus: isDesktop, @@ -428,49 +438,53 @@ class _AddWalletViewState extends ConsumerState { enableSuggestions: !isDesktop, controller: _searchFieldController, focusNode: _searchFocusNode, - onChanged: - (value) => setState(() => _searchTerm = value), + onChanged: (value) => + setState(() => _searchTerm = value), style: STextStyles.field(context), - decoration: standardInputDecoration( - "Search", - _searchFocusNode, - context, - desktopMed: isDesktop, - ).copyWith( - prefixIcon: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 10, - vertical: 16, - ), - child: SvgPicture.asset( - Assets.svg.search, - width: 16, - height: 16, - ), - ), - suffixIcon: - _searchFieldController.text.isNotEmpty + decoration: + standardInputDecoration( + "Search", + _searchFocusNode, + context, + desktopMed: isDesktop, + ).copyWith( + prefixIcon: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 16, + ), + child: SvgPicture.asset( + Assets.svg.search, + width: 16, + height: 16, + ), + ), + suffixIcon: + _searchFieldController.text.isNotEmpty ? Padding( - padding: const EdgeInsets.only(right: 0), - child: UnconstrainedBox( - child: Row( - children: [ - TextFieldIconButton( - child: const XIcon(), - onTap: () async { - setState(() { - _searchFieldController.text = - ""; - _searchTerm = ""; - }); - }, - ), - ], + padding: const EdgeInsets.only( + right: 0, ), - ), - ) + child: UnconstrainedBox( + child: Row( + children: [ + TextFieldIconButton( + child: const XIcon(), + onTap: () async { + setState(() { + _searchFieldController + .text = + ""; + _searchTerm = ""; + }); + }, + ), + ], + ), + ), + ) : null, - ), + ), ), ), ), diff --git a/lib/pages/add_wallet_views/add_wallet_view/sub_widgets/coin_select_item.dart b/lib/pages/add_wallet_views/add_wallet_view/sub_widgets/coin_select_item.dart index 9254847fcb..31352a3d1d 100644 --- a/lib/pages/add_wallet_views/add_wallet_view/sub_widgets/coin_select_item.dart +++ b/lib/pages/add_wallet_views/add_wallet_view/sub_widgets/coin_select_item.dart @@ -29,6 +29,7 @@ import '../../../../utilities/constants.dart'; import '../../../../utilities/text_styles.dart'; import '../../../../utilities/util.dart'; import '../../../../widgets/app_icon.dart'; +import '../../../../widgets/icon_widgets/eth_token_icon.dart'; class CoinSelectItem extends ConsumerStatefulWidget { const CoinSelectItem({super.key, required this.entity}); @@ -46,31 +47,7 @@ class _CoinSelectItemState extends ConsumerState { void initState() { super.initState(); - if (widget.entity is EthTokenEntity) { - ExchangeDataLoadingService.instance.isar.then((isar) async { - final currency = await isar.currencies - .where() - .exchangeNameEqualTo(ChangeNowExchange.exchangeName) - .filter() - .tokenContractEqualTo( - (widget.entity as EthTokenEntity).token.address, - caseSensitive: false, - ) - .and() - .imageIsNotEmpty() - .findFirst(); - - if (mounted) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) { - setState(() { - tokenImageUri = currency?.image; - }); - } - }); - } - }); - } else if (widget.entity is SolTokenEntity) { + if (widget.entity is SolTokenEntity) { final solToken = (widget.entity as SolTokenEntity).token; ExchangeDataLoadingService.instance.isar.then((isar) async { @@ -109,7 +86,6 @@ class _CoinSelectItemState extends ConsumerState { final selectedEntity = ref.watch(addWalletSelectedEntityStateProvider); final isDesktop = Util.isDesktop; - return Container( decoration: BoxDecoration( color: selectedEntity == widget.entity @@ -137,7 +113,13 @@ class _CoinSelectItemState extends ConsumerState { constraints: BoxConstraints(minHeight: isDesktop ? 70 : 0), child: Row( children: [ - tokenImageUri != null + widget.entity is EthTokenEntity + ? EthTokenIcon( + contractAddress: + (widget.entity as EthTokenEntity).token.address, + size: 26, + ) + : tokenImageUri != null ? tokenImageUri!.toLowerCase().endsWith(".svg") ? SvgPicture.network( tokenImageUri!, @@ -187,9 +169,9 @@ class _CoinSelectItemState extends ConsumerState { height: 24, child: SvgPicture.asset( Assets.svg.check, - color: Theme.of( - context, - ).extension()!.accentColorDark, + color: Theme.of(context) + .extension()! + .accentColorDark, ), ), ), diff --git a/lib/utilities/default_eth_tokens.dart b/lib/utilities/default_eth_tokens.dart index 63cd7f62b3..556a30dda3 100644 --- a/lib/utilities/default_eth_tokens.dart +++ b/lib/utilities/default_eth_tokens.dart @@ -11,21 +11,34 @@ import '../models/isar/models/ethereum/eth_contract.dart'; abstract class DefaultTokens { + static final usdc = EthContract( + address: "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", + name: "USD Coin", + symbol: "USDC", + decimals: 6, + type: EthContractType.erc20, + ); + + static final usdt = EthContract( + address: "0xdac17f958d2ee523a2206206994597c13d831ec7", + name: "Tether", + symbol: "USDT", + decimals: 6, + type: EthContractType.erc20, + ); + + // https://firo.org/2026/09/15/rsfiro-live.html + static final rsFiro = EthContract( + address: "0x2744ea5ac9b11cb5e3cd63d3a88e858336aeddc2", + name: "rsFIRO", + symbol: "rsFIRO", + decimals: 8, + type: EthContractType.erc20, + ); + static List list = [ - EthContract( - address: "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", - name: "USD Coin", - symbol: "USDC", - decimals: 6, - type: EthContractType.erc20, - ), - EthContract( - address: "0xdac17f958d2ee523a2206206994597c13d831ec7", - name: "Tether", - symbol: "USDT", - decimals: 6, - type: EthContractType.erc20, - ), + usdc, + usdt, EthContract( address: "0x95ad61b0a150d79219dcf64e1e6cc01f0b64c4ce", name: "Shiba Inu", @@ -47,5 +60,18 @@ abstract class DefaultTokens { decimals: 18, type: EthContractType.erc20, ), + rsFiro, ]; + + static final limited = [usdc, usdt, rsFiro]; + + /// Seed an empty catalog without restoring defaults the user has removed. + static bool seedIfEmpty( + List contracts, + List defaults, + ) { + if (contracts.isNotEmpty) return false; + contracts.addAll(defaults); + return contracts.isNotEmpty; + } } diff --git a/lib/widgets/icon_widgets/eth_token_icon.dart b/lib/widgets/icon_widgets/eth_token_icon.dart index 856474283e..609788f26e 100644 --- a/lib/widgets/icon_widgets/eth_token_icon.dart +++ b/lib/widgets/icon_widgets/eth_token_icon.dart @@ -19,6 +19,7 @@ import '../../models/isar/exchange_cache/currency.dart'; import '../../services/exchange/change_now/change_now_exchange.dart'; import '../../services/exchange/exchange_data_loading_service.dart'; import '../../themes/coin_icon_provider.dart'; +import '../../utilities/default_eth_tokens.dart'; import '../../wallets/crypto_currency/crypto_currency.dart'; import '../loading_indicator.dart'; @@ -38,10 +39,13 @@ class EthTokenIcon extends ConsumerStatefulWidget { class _EthTokenIconState extends ConsumerState { String? imageUrl; + bool get _hasBundledIcon => + widget.contractAddress.toLowerCase() == DefaultTokens.rsFiro.address; @override void initState() { super.initState(); + if (_hasBundledIcon) return; ExchangeDataLoadingService.instance.isar.then((isar) async { final currency = await isar.currencies @@ -67,13 +71,22 @@ class _EthTokenIconState extends ConsumerState { @override Widget build(BuildContext context) { - if (imageUrl == null || imageUrl!.isEmpty) { - return SvgPicture.file( - File(ref.watch(coinIconProvider(Ethereum(.main)))), + if (_hasBundledIcon) { + return SvgPicture.asset( + "assets/images/rsfiro.svg", width: widget.size, height: widget.size, ); - } else { + } + + final fallback = SvgPicture.file( + File(ref.watch(coinIconProvider(Ethereum(.main)))), + width: widget.size, + height: widget.size, + ); + if (imageUrl == null || imageUrl!.isEmpty) return fallback; + + if (imageUrl!.toLowerCase().endsWith(".svg")) { return SvgPicture.network( imageUrl!, width: widget.size, @@ -81,5 +94,12 @@ class _EthTokenIconState extends ConsumerState { placeholderBuilder: (_) => const LoadingIndicator(), ); } + + return Image.network( + imageUrl!, + width: widget.size, + height: widget.size, + errorBuilder: (_, _, _) => fallback, + ); } } diff --git a/scripts/app_config/configure_campfire.sh b/scripts/app_config/configure_campfire.sh index 40e03dfe26..b5c85ce9de 100755 --- a/scripts/app_config/configure_campfire.sh +++ b/scripts/app_config/configure_campfire.sh @@ -65,7 +65,9 @@ const _mwebdExeHash = ""; const Set _features = { AppFeature.tor, - AppFeature.swap + AppFeature.swap, + AppFeature.addCustomTokens, + AppFeature.limitedEthTokenDefaults }; const ({String light, String dark})? _appIconAsset = ( @@ -75,6 +77,7 @@ const ({String light, String dark})? _appIconAsset = ( final List _supportedCoins = List.unmodifiable([ Firo(CryptoCurrencyNetwork.main), + Ethereum(CryptoCurrencyNetwork.main), ]); final ({String from, String fromFuzzyNet, String to, String toFuzzyNet}) @@ -85,4 +88,4 @@ _swapDefaults = ( toFuzzyNet: "firo", ); -EOF \ No newline at end of file +EOF diff --git a/scripts/app_config/configure_stack_wallet.sh b/scripts/app_config/configure_stack_wallet.sh index ae16b70ae1..6ae854a28b 100755 --- a/scripts/app_config/configure_stack_wallet.sh +++ b/scripts/app_config/configure_stack_wallet.sh @@ -90,7 +90,8 @@ const Set _features = { AppFeature.tor, AppFeature.shopinBit, AppFeature.cakePay, - AppFeature.swap + AppFeature.swap, + AppFeature.addCustomTokens }; const ({String light, String dark})? _appIconAsset = null; @@ -143,4 +144,4 @@ _swapDefaults = ( toFuzzyNet: "xmr", ); -EOF \ No newline at end of file +EOF diff --git a/scripts/ensure_test_app_config.sh b/scripts/ensure_test_app_config.sh index 417da291a3..77c25a6c40 100755 --- a/scripts/ensure_test_app_config.sh +++ b/scripts/ensure_test_app_config.sh @@ -35,7 +35,8 @@ const Set _features = { AppFeature.themeSelection, AppFeature.buy, AppFeature.tor, - AppFeature.swap + AppFeature.swap, + AppFeature.addCustomTokens }; const ({String light, String dark})? _appIconAsset = null; diff --git a/test/utilities/default_eth_tokens_test.dart b/test/utilities/default_eth_tokens_test.dart new file mode 100644 index 0000000000..bf02622f81 --- /dev/null +++ b/test/utilities/default_eth_tokens_test.dart @@ -0,0 +1,93 @@ +import 'dart:io'; + +import 'package:flutter/widgets.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/models/isar/models/ethereum/eth_contract.dart'; +import 'package:stackwallet/themes/coin_icon_provider.dart'; +import 'package:stackwallet/utilities/default_eth_tokens.dart'; +import 'package:stackwallet/widgets/icon_widgets/eth_token_icon.dart'; + +void main() { + testWidgets('rsFIRO uses its bundled icon case-insensitively', ( + tester, + ) async { + await tester.pumpWidget( + ProviderScope( + child: Center( + child: EthTokenIcon( + contractAddress: DefaultTokens.rsFiro.address.toUpperCase(), + size: 26, + ), + ), + ), + ); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + expect(tester.getSize(find.byType(SvgPicture)), const Size.square(26)); + }); + + testWidgets('cached raster logos retain the coin-icon error fallback', ( + tester, + ) async { + final iconPath = + '${Directory.current.path}/test/sample_data/light/assets/dummy.svg'; + Widget icon(String address) => ProviderScope( + overrides: [ + coinIconProvider.overrideWithProvider( + (_) => Provider((_) => iconPath), + ), + ], + child: Center(child: EthTokenIcon(contractAddress: address, size: 26)), + ); + + // Start with the bundled icon to avoid opening the exchange database, + // then supply the resolved cache URL directly, as in other state tests. + await tester.pumpWidget(icon(DefaultTokens.rsFiro.address)); + final dynamic state = tester.state(find.byType(EthTokenIcon)); + const url = 'https://example.invalid/token.PNG'; + state.imageUrl = url; + await tester.pumpWidget(icon(DefaultTokens.usdc.address)); + + final image = tester.widget(find.byType(Image)); + expect((image.image as NetworkImage).url, url); + expect(tester.getSize(find.byType(Image)), const Size.square(26)); + final fallback = image.errorBuilder!( + tester.element(find.byType(Image)), + const HttpException('Image unavailable'), + null, + ) as SvgPicture; + expect((fallback.bytesLoader as SvgFileLoader).file.path, iconPath); + expect(fallback.width, 26); + expect(fallback.height, 26); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + }); + + test('rsFIRO has the official Ethereum mainnet ERC-20 metadata', () { + final token = DefaultTokens.rsFiro; + expect(token.address, '0x2744ea5ac9b11cb5e3cd63d3a88e858336aeddc2'); + expect(token.name, 'rsFIRO'); + expect(token.symbol, 'rsFIRO'); + expect(token.decimals, 8); + expect(token.type, EthContractType.erc20); + expect(DefaultTokens.list, contains(token)); + expect( + DefaultTokens.limited.map((token) => token.symbol), + orderedEquals(['USDC', 'USDT', 'rsFIRO']), + ); + }); + + test('defaults seed only an empty catalog', () { + final defaults = DefaultTokens.limited; + final contracts = []; + expect(DefaultTokens.seedIfEmpty(contracts, defaults), isTrue); + expect(contracts, orderedEquals(defaults)); + expect(DefaultTokens.seedIfEmpty(contracts, defaults), isFalse); + + final existing = [DefaultTokens.usdc]; + expect(DefaultTokens.seedIfEmpty(existing, defaults), isFalse); + expect(existing, [DefaultTokens.usdc]); + }); +}