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]);
+ });
+}