From ed168eddfa1b56c98d537f7d02209446cc29321e Mon Sep 17 00:00:00 2001 From: Nikolaus Heger Date: Fri, 9 Oct 2026 11:17:23 +0800 Subject: [PATCH 1/4] Show the balance in fiat again, priced from the 1Click listing QTC's USD price is the one 1Click lists it with, read every five minutes with a conditional GET: the client remembers each GET's ETag, so an unchanged listing answers 304 with no body. Reading the listed QTC no longer costs the CoinGecko ranking, which only the swap token list needs. The exchange rate service carries the price as nullable and refuses to convert without one, so the fiat line under the balance only appears once a real price is known, and the hide toggle masks it too. --- .../providers/currency_display_provider.dart | 73 +++++++++++++++---- .../lib/services/exchange_rate_service.dart | 18 +++-- .../amount_display_with_conversion.dart | 66 ++++++++++------- .../test/unit/amount_input_logic_test.dart | 2 +- .../test/unit/balance_fiat_line_test.dart | 72 ++++++++++++++++++ .../test/unit/exchange_rate_service_test.dart | 11 ++- .../lib/src/services/one_click_service.dart | 10 ++- .../lib/src/services/swap_service.dart | 37 +++++----- .../test/services/swap_service_test.dart | 29 ++++++++ 9 files changed, 246 insertions(+), 72 deletions(-) create mode 100644 mobile-app/test/unit/balance_fiat_line_test.dart diff --git a/mobile-app/lib/providers/currency_display_provider.dart b/mobile-app/lib/providers/currency_display_provider.dart index 45b49962a..428b34c2d 100644 --- a/mobile-app/lib/providers/currency_display_provider.dart +++ b/mobile-app/lib/providers/currency_display_provider.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:convert'; import 'package:decimal/decimal.dart'; @@ -9,6 +10,7 @@ import 'package:resonance_network_wallet/providers/account_providers.dart'; import 'package:resonance_network_wallet/providers/wallet_providers.dart'; import 'package:resonance_network_wallet/services/exchange_rate_service.dart'; import 'package:resonance_network_wallet/shared/utils/print.dart'; +import 'package:resonance_network_wallet/v2/screens/swap/swap_providers.dart'; // --------------------------------------------------------------------------- // Exchange rate caching helpers @@ -109,6 +111,38 @@ final exchangeRatesProvider = FutureProvider>((ref) async { } }); +// --------------------------------------------------------------------------- +// Token price provider +// --------------------------------------------------------------------------- + +/// How often 1Click is asked for QTC's price; an unchanged listing costs a 304. +const tokenPriceRefreshInterval = Duration(minutes: 5); + +/// QTC's USD price as 1Click lists it, refreshed every +/// [tokenPriceRefreshInterval]. A failed refresh is logged and the last price +/// stands; there is none until the first lands. +final tokenUsdPriceProvider = StreamProvider((ref) { + final swap = ref.watch(swapServiceProvider); + final prices = StreamController(); + + Future refresh() async { + try { + final listed = await swap.getListedQuantusToken(forceRefresh: true); + if (listed != null && !prices.isClosed) prices.add(Decimal.parse(listed.usdPrice.toString())); + } catch (e) { + quantusPrint('QTC price refresh failed: $e'); + } + } + + final timer = Timer.periodic(tokenPriceRefreshInterval, (_) => refresh()); + ref.onDispose(() { + timer.cancel(); + prices.close(); + }); + unawaited(refresh()); + return prices.stream; +}); + // --------------------------------------------------------------------------- // Exchange rate service provider // --------------------------------------------------------------------------- @@ -118,14 +152,19 @@ final exchangeRatesProvider = FutureProvider>((ref) async { /// • While [exchangeRatesProvider] is loading, uses the last persisted rates /// (any age) so the UI always shows something meaningful. /// • Once live rates arrive, rebuilds with the fresh data. +/// • Carries QTC's USD price once [tokenUsdPriceProvider] has one. final exchangeRateServiceProvider = Provider((ref) { final ratesAsync = ref.watch(exchangeRatesProvider); final settings = ref.read(settingsServiceProvider); + final tokenToUsdRate = ref.watch(tokenUsdPriceProvider).value; + + ExchangeRateService service(Map rates) => + ExchangeRateService(rates: rates, tokenToUsdRate: tokenToUsdRate); return ratesAsync.when( - data: (rates) => ExchangeRateService(rates: rates), - loading: () => ExchangeRateService(rates: _readRatesCacheAnyAge(settings)), - error: (_, _) => ExchangeRateService(rates: _readRatesCacheAnyAge(settings)), + data: service, + loading: () => service(_readRatesCacheAnyAge(settings)), + error: (_, _) => service(_readRatesCacheAnyAge(settings)), ); }); @@ -178,10 +217,12 @@ class SelectedFiatCurrencyNotifier extends StateNotifier { /// No conversion math belongs in widgets. class CurrencyDisplayState { final String primaryAmount; - final String secondaryAmount; + + /// The amount in [selectedFiat]; null when it is not to be shown or no token price is known. + final String? secondaryAmount; final FiatCurrency selectedFiat; - const CurrencyDisplayState({required this.primaryAmount, required this.secondaryAmount, required this.selectedFiat}); + const CurrencyDisplayState({required this.primaryAmount, this.secondaryAmount, required this.selectedFiat}); CurrencyDisplayState copyWith({String? primaryAmount, String? secondaryAmount, FiatCurrency? selectedFiat}) => CurrencyDisplayState( @@ -214,18 +255,17 @@ final balanceDisplayProvider = Provider>((ref) return balanceAsync.when( loading: () => const AsyncValue.loading(), error: (err, stack) => AsyncValue.error(err, stack), - data: (balance) { - CurrencyDisplayState data = _toFiatDisplayState( + data: (balance) => AsyncValue.data( + _toFiatDisplayState( balance, selectedFiat, - xRate, fmt, tokenDecimals: 3, withTokenSymbol: false, localeConfig: localeConfig, - ); - return AsyncValue.data(data); - }, + fiatRate: xRate, + ), + ), ); }); @@ -244,7 +284,6 @@ typedef TxAmountFormatter = final txAmountDisplayProvider = Provider((ref) { final selectedFiat = ref.watch(selectedFiatCurrencyProvider); - final xRate = ref.watch(exchangeRateServiceProvider); final fmt = ref.watch(numberFormattingServiceProvider); final localeConfig = ref.watch(localeNumberConfigProvider); @@ -260,7 +299,6 @@ final txAmountDisplayProvider = Provider((ref) { CurrencyDisplayState data = _toFiatDisplayState( amount, selectedFiat, - xRate, fmt, tokenDecimals: tokenDecimals, withTokenSymbol: withTokenSymbol, @@ -294,21 +332,24 @@ String _toFiatNumeric( return localeConfig.localize(canonical); } +/// [fiatRate] adds the fiat amount; it stays out while no token price is known. CurrencyDisplayState _toFiatDisplayState( BigInt amount, FiatCurrency selectedFiat, - ExchangeRateService xRate, NumberFormattingService fmt, { required int tokenDecimals, required bool withTokenSymbol, required LocaleNumberConfig localeConfig, + ExchangeRateService? fiatRate, }) { final tokenFormatted = fmt.formatBalance(amount, smartDecimals: tokenDecimals, addSymbol: withTokenSymbol); - final fiatFormatted = selectedFiat.format(_toFiatNumeric(amount, selectedFiat, xRate, localeConfig: localeConfig)); + final withFiat = fiatRate != null && fiatRate.hasTokenPrice; return CurrencyDisplayState( primaryAmount: tokenFormatted, - secondaryAmount: fiatFormatted, + secondaryAmount: withFiat + ? selectedFiat.format(_toFiatNumeric(amount, selectedFiat, fiatRate, localeConfig: localeConfig)) + : null, selectedFiat: selectedFiat, ); } diff --git a/mobile-app/lib/services/exchange_rate_service.dart b/mobile-app/lib/services/exchange_rate_service.dart index 54d8b3156..fe5baebd2 100644 --- a/mobile-app/lib/services/exchange_rate_service.dart +++ b/mobile-app/lib/services/exchange_rate_service.dart @@ -6,8 +6,8 @@ import 'package:resonance_network_wallet/models/fiat_currency.dart'; /// Constructed with a live [rates] map (ISO-4217 code → value in that currency /// per 1 USD). Falls back to [fallbackRates] for any code not present. /// -/// [tokenToUsdRate] defaults to `1` (1 token = 1 USD). Wire a dedicated tokens -/// price feed into this field when one becomes available. +/// [tokenToUsdRate] is the token's USD price, null while none is known; +/// converting without one throws. class ExchangeRateService { /// Static rates used before any live or cached data is available (e.g. on /// fresh install with no network). Values are approximate and intentionally @@ -23,11 +23,13 @@ class ExchangeRateService { }; final Map _rates; - final Decimal tokenToUsdRate; + final Decimal? tokenToUsdRate; - ExchangeRateService({required Map rates, Decimal? tokenToUsdRate}) - : _rates = rates, - tokenToUsdRate = tokenToUsdRate ?? Decimal.one; + ExchangeRateService({required Map rates, this.tokenToUsdRate}) : _rates = rates; + + bool get hasTokenPrice => tokenToUsdRate != null; + + Decimal get _tokenPrice => tokenToUsdRate ?? (throw StateError('Token price unknown')); /// Returns the exchange rate for [fiat] (units per 1 USD). Decimal getRate(FiatCurrency fiat) { @@ -39,7 +41,7 @@ class ExchangeRateService { /// Converts [tokenAmount] to [fiat] using the current rates. Decimal convert(Decimal tokenAmount, FiatCurrency fiat) { - final result = (tokenAmount * tokenToUsdRate * getRate(fiat)); + final result = (tokenAmount * _tokenPrice * getRate(fiat)); // Round to fiat precision to ensure stable round-trips return Decimal.parse(result.toStringAsFixed(fiat.decimals)); } @@ -59,7 +61,7 @@ class ExchangeRateService { /// Uses the inverse of [convert]: fiat / (tokenToUsdRate × rate). /// Returns [BigInt.zero] when the effective rate is zero. BigInt fiatToToken(Decimal fiatAmount, FiatCurrency fiat, int tokenDecimals) { - final effectiveRate = tokenToUsdRate * getRate(fiat); + final effectiveRate = _tokenPrice * getRate(fiat); if (effectiveRate == Decimal.zero) return BigInt.zero; final scaleFactor = Decimal.fromBigInt(BigInt.from(10).pow(tokenDecimals)); final tokenDecimal = (fiatAmount / effectiveRate).toDecimal(scaleOnInfinitePrecision: tokenDecimals); diff --git a/mobile-app/lib/v2/components/amount_display_with_conversion.dart b/mobile-app/lib/v2/components/amount_display_with_conversion.dart index 33f5d59fc..afab85dd0 100644 --- a/mobile-app/lib/v2/components/amount_display_with_conversion.dart +++ b/mobile-app/lib/v2/components/amount_display_with_conversion.dart @@ -13,7 +13,7 @@ class AmountDisplayWithConversion extends StatelessWidget { final Color? amountColor; final bool useTokenLogo; - /// Masks the amount with [hiddenAmountText]. Owned by the screen that + /// Masks the amounts with [hiddenAmountText]. Owned by the screen that /// offers the hide toggle — never read from a global setting here. final bool isHidden; @@ -36,41 +36,55 @@ class AmountDisplayWithConversion extends StatelessWidget { const tokenLogoPrimarySize = 32.0; final primaryAmount = isHidden ? hiddenAmountText : amountDisplay.primaryAmount; + final secondaryAmount = amountDisplay.secondaryAmount; final MainAxisAlignment mainAxisAlignment = switch (alignment) { CrossAxisAlignment.center => MainAxisAlignment.center, _ => MainAxisAlignment.start, }; - return Row( - mainAxisAlignment: mainAxisAlignment, + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: alignment, children: [ - if (useTokenLogo) ...[ - SvgPicture.asset( - 'assets/v2/uppercase_q.svg', - width: tokenLogoPrimarySize, - height: tokenLogoPrimarySize, - colorFilter: ColorFilter.mode(colors.textContent, BlendMode.srcIn), - ), - const SizedBox(width: 4), - ], - Text.rich( - TextSpan( - children: [ - TextSpan( - text: primaryAmount, - style: text.displayBalance.copyWith(color: primaryAmountColor), + Row( + mainAxisAlignment: mainAxisAlignment, + children: [ + if (useTokenLogo) ...[ + SvgPicture.asset( + 'assets/v2/uppercase_q.svg', + width: tokenLogoPrimarySize, + height: tokenLogoPrimarySize, + colorFilter: ColorFilter.mode(colors.textContent, BlendMode.srcIn), ), - if (!useTokenLogo) ...[ - const TextSpan(text: ' '), - TextSpan( - text: AppConstants.tokenSymbol, - style: text.amountHero.copyWith(color: primaryAmountColor), - ), - ], + const SizedBox(width: 4), ], - ), + Text.rich( + TextSpan( + children: [ + TextSpan( + text: primaryAmount, + style: text.displayBalance.copyWith(color: primaryAmountColor), + ), + if (!useTokenLogo) ...[ + const TextSpan(text: ' '), + TextSpan( + text: AppConstants.tokenSymbol, + style: text.amountHero.copyWith(color: primaryAmountColor), + ), + ], + ], + ), + ), + ], ), + if (secondaryAmount != null) ...[ + const SizedBox(height: 8), + Text( + '≈ ${isHidden ? hiddenAmountText : secondaryAmount}', + style: text.body.copyWith(color: colors.textMuted), + ), + ], ], ); } diff --git a/mobile-app/test/unit/amount_input_logic_test.dart b/mobile-app/test/unit/amount_input_logic_test.dart index d2d618d0f..931312a9a 100644 --- a/mobile-app/test/unit/amount_input_logic_test.dart +++ b/mobile-app/test/unit/amount_input_logic_test.dart @@ -12,7 +12,7 @@ void main() { late NumberFormattingService formattingService; setUp(() { - exchangeRateService = ExchangeRateService(rates: {'USD': Decimal.one}); + exchangeRateService = ExchangeRateService(rates: {'USD': Decimal.one}, tokenToUsdRate: Decimal.one); localeConfig = LocaleNumberConfig.dotDecimal; formattingService = NumberFormattingService(localeConfig: localeConfig); }); diff --git a/mobile-app/test/unit/balance_fiat_line_test.dart b/mobile-app/test/unit/balance_fiat_line_test.dart new file mode 100644 index 000000000..2793c6712 --- /dev/null +++ b/mobile-app/test/unit/balance_fiat_line_test.dart @@ -0,0 +1,72 @@ +import 'package:decimal/decimal.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:quantus_sdk/quantus_sdk.dart'; +import 'package:resonance_network_wallet/models/fiat_currency.dart'; +import 'package:resonance_network_wallet/providers/currency_display_provider.dart'; +import 'package:resonance_network_wallet/providers/wallet_providers.dart'; +import 'package:resonance_network_wallet/v2/components/amount_display_with_conversion.dart'; + +import '../extensions.dart'; +import '../fakes.dart'; + +void main() { + final oneAndAHalf = BigInt.from(15) * BigInt.from(10).pow(11); + + ProviderContainer container({Decimal? price}) { + final c = ProviderContainer( + overrides: [ + settingsServiceProvider.overrideWithValue(FakeSettingsService(activeAccount: RegularAccount(makeAccount(1)))), + balanceProvider.overrideWithValue(AsyncValue.data(oneAndAHalf)), + exchangeRatesProvider.overrideWith((ref) async => {'USD': Decimal.one}), + tokenUsdPriceProvider.overrideWith( + (ref) => price == null ? const Stream.empty() : Stream.value(price), + ), + ], + ); + addTearDown(c.dispose); + return c; + } + + /// Listens, which is what runs the stream, and waits for the rates and any price to land. + Future ready({Decimal? price}) async { + final c = container(price: price); + c.listen(balanceDisplayProvider, (_, _) {}); + await c.read(exchangeRatesProvider.future); + if (price != null) await c.read(tokenUsdPriceProvider.future); + return c; + } + + test('the balance carries no fiat amount until QTC has a price', () async { + final c = await ready(); + expect(c.read(balanceDisplayProvider).requireValue.secondaryAmount, isNull); + }); + + test('the balance is priced in the selected fiat once QTC has a price', () async { + final c = await ready(price: Decimal.parse('2')); + expect(c.read(balanceDisplayProvider).requireValue.secondaryAmount, r'$3.00'); + }); + + test('transaction amounts carry no fiat amount', () async { + final c = await ready(price: Decimal.parse('2')); + expect(c.read(txAmountDisplayProvider)(oneAndAHalf, isSend: true).secondaryAmount, isNull); + }); + + testWidgets('the fiat line shows under the amount and hides with it', (tester) async { + const priced = CurrencyDisplayState( + primaryAmount: '1.5', + secondaryAmount: r'$3.00', + selectedFiat: FiatCurrency.usd, + ); + await tester.pumpApp(const AmountDisplayWithConversion(amountDisplay: priced)); + expect(find.text(r'≈ $3.00'), findsOneWidget); + + await tester.pumpApp(const AmountDisplayWithConversion(amountDisplay: priced, isHidden: true)); + expect(find.text('≈ $hiddenAmountText'), findsOneWidget); + + const unpriced = CurrencyDisplayState(primaryAmount: '1.5', selectedFiat: FiatCurrency.usd); + await tester.pumpApp(const AmountDisplayWithConversion(amountDisplay: unpriced)); + expect(find.textContaining('≈'), findsNothing); + }); +} diff --git a/mobile-app/test/unit/exchange_rate_service_test.dart b/mobile-app/test/unit/exchange_rate_service_test.dart index 7bd51b848..7ba4411e4 100644 --- a/mobile-app/test/unit/exchange_rate_service_test.dart +++ b/mobile-app/test/unit/exchange_rate_service_test.dart @@ -10,7 +10,7 @@ void main() { late ExchangeRateService service; setUp(() { - service = ExchangeRateService(rates: rates); + service = ExchangeRateService(rates: rates, tokenToUsdRate: Decimal.one); }); group('ExchangeRateService.getRate', () { @@ -38,6 +38,13 @@ void main() { expect(service.convert(Decimal.parse('0.5'), FiatCurrency.myr), Decimal.parse('1.99')); }); + test('throws while the token price is unknown', () { + final unpriced = ExchangeRateService(rates: rates); + expect(unpriced.hasTokenPrice, isFalse); + expect(() => unpriced.convert(Decimal.one, FiatCurrency.usd), throwsStateError); + expect(() => unpriced.fiatToToken(Decimal.one, FiatCurrency.usd, 12), throwsStateError); + }); + test('applies tokenToUsdRate when set', () { final serviceWith2xRate = ExchangeRateService(rates: rates, tokenToUsdRate: Decimal.parse('2')); // 1 token × 2 USD/token × 3.97 MYR/USD = 7.94 MYR @@ -103,7 +110,7 @@ void main() { // we ensure that the round-tripped tokens value is the canonical tokens // representation of that specific fiat amount. const tokenDecimals = 12; - final lossyService = ExchangeRateService(rates: {'MYR': Decimal.parse('3.971')}); + final lossyService = ExchangeRateService(rates: {'MYR': Decimal.parse('3.971')}, tokenToUsdRate: Decimal.one); final original = BigInt.from(1_500_000_000_000); // 1.5 tokens // 1.5 * 3.971 = 5.9565 -> rounded to 5.96 MYR diff --git a/quantus_sdk/lib/src/services/one_click_service.dart b/quantus_sdk/lib/src/services/one_click_service.dart index 7f4f020d8..65b2be5fa 100644 --- a/quantus_sdk/lib/src/services/one_click_service.dart +++ b/quantus_sdk/lib/src/services/one_click_service.dart @@ -28,6 +28,10 @@ class OneClickService { /// issues an address that already exists, so neither answer goes stale. final _depositAddresses = {}; + /// The last answer to each GET with its ETag, so asking again costs a 304 + /// and no body while the resource is unchanged. + final _etagged = {}; + OneClickService({http.Client? client, String endpoint = AppConstants.oneClickEndpoint, String? apiKey}) : _client = client ?? http.Client(), _base = Uri.parse(endpoint), @@ -52,10 +56,12 @@ class OneClickService { Future send(String method, String path, {Map? body, Map? query}) async { final uri = _base.replace(path: path, queryParameters: query); - final headers = {'Content-Type': 'application/json', 'X-API-Key': ?_apiKey}; + final cached = method == 'GET' ? _etagged[uri] : null; + final headers = {'Content-Type': 'application/json', 'X-API-Key': ?_apiKey, 'If-None-Match': ?cached?.$1}; final response = method == 'GET' ? await _client.get(uri, headers: headers) : await _client.post(uri, headers: headers, body: jsonEncode(body)); + if (response.statusCode == 304 && cached != null) return cached.$2; final ok = response.statusCode >= 200 && response.statusCode < 300; Object? json; try { @@ -71,6 +77,8 @@ class OneClickService { _ => response.body, }); } + final etag = response.headers['etag']; + if (method == 'GET' && etag != null) _etagged[uri] = (etag, json); return json; } } diff --git a/quantus_sdk/lib/src/services/swap_service.dart b/quantus_sdk/lib/src/services/swap_service.dart index b1f1020d7..71352e963 100644 --- a/quantus_sdk/lib/src/services/swap_service.dart +++ b/quantus_sdk/lib/src/services/swap_service.dart @@ -74,9 +74,9 @@ class SwapService { final String _managerPublicKey; final String? _configuredQuantusAssetId; final bool _preflight; + (List, SwapToken?)? _cachedListing; + DateTime? _cachedListingAt; List? _cachedFromTokens; - DateTime? _cachedFromTokensAt; - SwapToken? _listedQuantus; /// [quantusAssetId] names the listing that is QTC when its chain code does /// not; it comes from remote config so a surprising listing needs no update. @@ -99,26 +99,27 @@ class SwapService { static Duration depositWindowFor(String network) => _slowNetworks.contains(network) ? _slowDepositWindow : depositWindow; - Future> getFromTokens({int limit = 10, bool forceRefresh = false}) async => - (await _tokens(forceRefresh: forceRefresh)).take(limit).toList(); + Future> getFromTokens({int limit = 10, bool forceRefresh = false}) async { + final (tokens, _) = await _listing(forceRefresh: forceRefresh); + final ranked = _cachedFromTokens ??= await _rankByCoinGecko(tokens); + return ranked.take(limit).toList(); + } /// QTC as 1Click lists it, with its asset id, decimals and price; null until - /// it is listed with a price, and swaps are unavailable until then. - Future getListedQuantusToken({bool forceRefresh = false}) async { - await _tokens(forceRefresh: forceRefresh); - return _listedQuantus; - } + /// it is listed with a price, and swaps are unavailable until then. Costs + /// the listing alone, never the CoinGecko ranking. + Future getListedQuantusToken({bool forceRefresh = false}) async => + (await _listing(forceRefresh: forceRefresh)).$2; - Future> _tokens({required bool forceRefresh}) async { + Future<(List, SwapToken?)> _listing({required bool forceRefresh}) async { final now = DateTime.now(); - final cached = _cachedFromTokens; - if (!forceRefresh && cached != null && now.difference(_cachedFromTokensAt!) < _tokensCacheTtl) return cached; - final (tokens, quantus) = await _fetchIntentsTokens(); - final ranked = await _rankByCoinGecko(tokens); - _cachedFromTokens = ranked; - _listedQuantus = quantus; - _cachedFromTokensAt = now; - return ranked; + final cached = _cachedListing; + if (!forceRefresh && cached != null && now.difference(_cachedListingAt!) < _tokensCacheTtl) return cached; + final listing = await _fetchIntentsTokens(); + _cachedListing = listing; + _cachedListingAt = now; + _cachedFromTokens = null; + return listing; } /// Asks solvers for a price on [amount] base units of [from]. A dry quote is diff --git a/quantus_sdk/test/services/swap_service_test.dart b/quantus_sdk/test/services/swap_service_test.dart index 938e4f64c..d304bed7a 100644 --- a/quantus_sdk/test/services/swap_service_test.dart +++ b/quantus_sdk/test/services/swap_service_test.dart @@ -619,6 +619,35 @@ void main() { }, ); + test('the listed QTC costs the listing alone, never CoinGecko', () async { + final service = _service((r) async { + if (r.url.host == 'api.coingecko.com') fail('CoinGecko was asked for the price of QTC'); + return http.Response(jsonEncode(tokens), 200); + }); + expect((await service.getListedQuantusToken())!.usdPrice, 1.5); + }); + + test('revalidates the listing by its ETag and keeps the body on a 304', () async { + final sent = []; + var price = 1.5; + var version = 'W/"v1"'; + final service = _service((r) async { + if (r.url.host == 'api.coingecko.com') return http.Response('down', 503); + sent.add(r.headers['If-None-Match']); + if (r.headers['If-None-Match'] == version) return http.Response('', 304); + final body = [ + for (final t in tokens) t['symbol'] == 'QTC' ? {...t, 'price': price} : t, + ]; + return http.Response(jsonEncode(body), 200, headers: {'etag': version}); + }); + expect((await service.getListedQuantusToken())!.usdPrice, 1.5); + expect((await service.getListedQuantusToken(forceRefresh: true))!.usdPrice, 1.5); + price = 2; + version = 'W/"v2"'; + expect((await service.getListedQuantusToken(forceRefresh: true))!.usdPrice, 2); + expect(sent, [null, 'W/"v1"', 'W/"v1"']); + }); + test('has no listed QTC until 1Click adds it', () async { final service = listing(tokens.where((t) => t['symbol'] != 'QTC').toList()); expect(await service.getListedQuantusToken(), isNull); From 0a4edfb4eaac01a5ed05aaf659d715beaf548ed4 Mon Sep 17 00:00:00 2001 From: Nikolaus Heger Date: Fri, 9 Oct 2026 11:41:00 +0800 Subject: [PATCH 2/4] Drop the unused material import from the balance fiat test --- mobile-app/test/unit/balance_fiat_line_test.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/mobile-app/test/unit/balance_fiat_line_test.dart b/mobile-app/test/unit/balance_fiat_line_test.dart index 2793c6712..711c05194 100644 --- a/mobile-app/test/unit/balance_fiat_line_test.dart +++ b/mobile-app/test/unit/balance_fiat_line_test.dart @@ -1,5 +1,4 @@ import 'package:decimal/decimal.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:quantus_sdk/quantus_sdk.dart'; From f2fd9db91b6a1d4db82dd9481a1029f2c6aa594f Mon Sep 17 00:00:00 2001 From: Nikolaus Heger Date: Fri, 9 Oct 2026 11:21:57 +0800 Subject: [PATCH 3/4] Order swap tokens as 1Click lists them, QTC first and USDC second The swap list no longer asks CoinGecko for a market-cap ranking or for token logos. It is 1Click's own listing, one asset per symbol, with QTC moved to the top and USDC right after it; every other token keeps its place. QTC now appears in the token picker, and picking it for the other side moves QTC there. Token icons are the monograms the picker already fell back to. --- mobile-app/lib/v2/components/token_icon.dart | 24 +--- .../lib/v2/screens/swap/swap_screen.dart | 14 +-- quantus_sdk/lib/src/models/swap_token.dart | 15 --- .../lib/src/services/swap_service.dart | 106 ++---------------- .../test/services/swap_service_test.dart | 54 +++------ 5 files changed, 39 insertions(+), 174 deletions(-) diff --git a/mobile-app/lib/v2/components/token_icon.dart b/mobile-app/lib/v2/components/token_icon.dart index 0753ecad9..7a06c4047 100644 --- a/mobile-app/lib/v2/components/token_icon.dart +++ b/mobile-app/lib/v2/components/token_icon.dart @@ -15,8 +15,6 @@ class TokenIcon extends StatelessWidget { Widget build(BuildContext context) { final colors = context.colorsV3; final text = context.themeTextV3; - final iconUrl = token.iconUrl; - final networkIconUrl = token.networkIconUrl; if (token.isQuantus) { return Container( @@ -34,28 +32,14 @@ class TokenIcon extends StatelessWidget { child: Stack( clipBehavior: Clip.none, children: [ - Positioned.fill( - child: ClipOval( - child: iconUrl != null - ? Image.network(iconUrl, fit: BoxFit.cover, errorBuilder: (_, _, _) => _fallback(token, colors, text)) - : _fallback(token, colors, text), - ), - ), + Positioned.fill(child: _monogram(token, colors, text)), Positioned( right: -2, bottom: -2, child: SizedBox( width: networkBadgeSize, height: networkBadgeSize, - child: ClipOval( - child: networkIconUrl != null - ? Image.network( - networkIconUrl, - fit: BoxFit.cover, - errorBuilder: (_, _, _) => _networkFallback(token, colors, text), - ) - : _networkFallback(token, colors, text), - ), + child: _networkMonogram(token, colors, text), ), ), ], @@ -63,7 +47,7 @@ class TokenIcon extends StatelessWidget { ); } - Widget _fallback(SwapToken token, AppColorsV3 colors, AppTextThemeV3 text) { + Widget _monogram(SwapToken token, AppColorsV3 colors, AppTextThemeV3 text) { return Container( decoration: BoxDecoration( color: colors.bgSurface2, @@ -81,7 +65,7 @@ class TokenIcon extends StatelessWidget { ); } - Widget _networkFallback(SwapToken token, AppColorsV3 colors, AppTextThemeV3 text) { + Widget _networkMonogram(SwapToken token, AppColorsV3 colors, AppTextThemeV3 text) { return Container( decoration: BoxDecoration( color: colors.bgVoid, diff --git a/mobile-app/lib/v2/screens/swap/swap_screen.dart b/mobile-app/lib/v2/screens/swap/swap_screen.dart index 7180d22e8..26abeeac0 100644 --- a/mobile-app/lib/v2/screens/swap/swap_screen.dart +++ b/mobile-app/lib/v2/screens/swap/swap_screen.dart @@ -74,7 +74,7 @@ class _SwapScreenState extends ConsumerState { final quantus = await service.getListedQuantusToken(); if (!mounted || load != _loads) return; setState(() { - _foreign ??= tokens.first; + _foreign ??= tokens.firstWhere((t) => !t.isQuantus); _quantus = quantus; }); } catch (e) { @@ -95,18 +95,18 @@ class _SwapScreenState extends ConsumerState { _amountController.clear(); } - /// Picks the external token. Picking from the QTC side moves QTC to the other side. + /// Picks a side's token. QTC heads the list: picking it for the other side + /// moves QTC there, as does picking a foreign token for QTC's side. Future _pickToken(SwapToken tapped) async { final service = ref.read(swapServiceProvider); - final foreign = _foreign!; final token = await showTokenPickerSheet( context, - current: foreign, + current: tapped, loadTokens: ({bool forceRefresh = false}) => service.getFromTokens(limit: 10, forceRefresh: forceRefresh), ); - if (token == null || !mounted) return; - setState(() => _foreign = token); - if (tapped.isQuantus) _setDirection(swapOut: !_swapOut); + if (token == null || !mounted || token == tapped) return; + if (!token.isQuantus) setState(() => _foreign = token); + if (token.isQuantus != tapped.isQuantus) _setDirection(swapOut: !_swapOut); } /// The sheet quotes with the service of the moment, so a config change diff --git a/quantus_sdk/lib/src/models/swap_token.dart b/quantus_sdk/lib/src/models/swap_token.dart index 22849cb90..3c7394ea1 100644 --- a/quantus_sdk/lib/src/models/swap_token.dart +++ b/quantus_sdk/lib/src/models/swap_token.dart @@ -33,8 +33,6 @@ class SwapToken { final String network; final int decimals; final double usdPrice; - final String? iconUrl; - final String? networkIconUrl; /// QTC's side of a swap: the token that is QTC, or stands in for it. final bool isQuantus; @@ -45,25 +43,12 @@ class SwapToken { required this.network, required this.decimals, required this.usdPrice, - this.iconUrl, - this.networkIconUrl, this.isQuantus = false, }); /// Human name of [network], e.g. "Ethereum" for ETH; the code itself when unknown. String get networkName => _networkNames[network] ?? network; - SwapToken copyWith({String? iconUrl, String? networkIconUrl}) => SwapToken( - assetId: assetId, - symbol: symbol, - network: network, - decimals: decimals, - usdPrice: usdPrice, - iconUrl: iconUrl ?? this.iconUrl, - networkIconUrl: networkIconUrl ?? this.networkIconUrl, - isQuantus: isQuantus, - ); - @override bool operator ==(Object other) => other is SwapToken && assetId == other.assetId; diff --git a/quantus_sdk/lib/src/services/swap_service.dart b/quantus_sdk/lib/src/services/swap_service.dart index 71352e963..7c342da2e 100644 --- a/quantus_sdk/lib/src/services/swap_service.dart +++ b/quantus_sdk/lib/src/services/swap_service.dart @@ -65,18 +65,14 @@ class SwapService { static const statusPollInterval = Duration(seconds: 5); static const _savedAddressesKey = 'swap_saved_addresses'; static const _maxSavedAddresses = 50; - static const _coinGeckoTopUrl = - 'https://api.coingecko.com/api/v3/coins/markets?vs_currency=usd&order=market_cap_desc&per_page=150&page=1&sparkline=false'; static const _tokensCacheTtl = Duration(minutes: 10); - final http.Client _client; final OneClickService _api; final String _managerPublicKey; final String? _configuredQuantusAssetId; final bool _preflight; (List, SwapToken?)? _cachedListing; DateTime? _cachedListingAt; - List? _cachedFromTokens; /// [quantusAssetId] names the listing that is QTC when its chain code does /// not; it comes from remote config so a surprising listing needs no update. @@ -89,8 +85,7 @@ class SwapService { String managerPublicKey = AppConstants.oneClickManagerPublicKey, String? quantusAssetId, bool preflight = false, - }) : _client = client ?? http.Client(), - _api = OneClickService(client: client, endpoint: endpoint, apiKey: apiKey), + }) : _api = OneClickService(client: client, endpoint: endpoint, apiKey: apiKey), _managerPublicKey = managerPublicKey, _configuredQuantusAssetId = quantusAssetId, _preflight = preflight; @@ -99,15 +94,16 @@ class SwapService { static Duration depositWindowFor(String network) => _slowNetworks.contains(network) ? _slowDepositWindow : depositWindow; + /// The tokens to swap between: QTC first, USDC second, the rest as 1Click + /// orders them, one asset per symbol. Future> getFromTokens({int limit = 10, bool forceRefresh = false}) async { - final (tokens, _) = await _listing(forceRefresh: forceRefresh); - final ranked = _cachedFromTokens ??= await _rankByCoinGecko(tokens); - return ranked.take(limit).toList(); + final (tokens, quantus) = await _listing(forceRefresh: forceRefresh); + final usdc = tokens.where((t) => t.symbol == 'USDC').firstOrNull; + return [?quantus, ?usdc, ...tokens.where((t) => t != usdc)].take(limit).toList(); } /// QTC as 1Click lists it, with its asset id, decimals and price; null until - /// it is listed with a price, and swaps are unavailable until then. Costs - /// the listing alone, never the CoinGecko ranking. + /// it is listed with a price, and swaps are unavailable until then. Future getListedQuantusToken({bool forceRefresh = false}) async => (await _listing(forceRefresh: forceRefresh)).$2; @@ -118,7 +114,6 @@ class SwapService { final listing = await _fetchIntentsTokens(); _cachedListing = listing; _cachedListingAt = now; - _cachedFromTokens = null; return listing; } @@ -280,8 +275,9 @@ class SwapService { return SwapOrder.fromStatusJson(json as Map, quote: order.quote); } - /// The tokens to swap with, one per symbol, and QTC as listed. A token is on - /// Quantus when 1Click says so or when its asset id is the configured one. + /// The tokens to swap with, one per symbol in 1Click's order, and QTC as + /// listed. A token is on Quantus when 1Click says so or when its asset id is + /// the configured one. Future<(List, SwapToken?)> _fetchIntentsTokens() async { final data = await _api.send('GET', '/v0/tokens') as List; final bySymbol = {}; @@ -297,7 +293,6 @@ class SwapService { network: network, decimals: (item['decimals'] as num).toInt(), usdPrice: (item['price'] as num?)?.toDouble() ?? 0, - networkIconUrl: _networkIconUrl(network), isQuantus: ours, ); if (ours) { @@ -347,41 +342,6 @@ class SwapService { return listed; } - /// Orders [tokens] by CoinGecko market cap and picks up their icons. A - /// CoinGecko failure only costs the ordering, so it is logged, not thrown. - Future> _rankByCoinGecko(List tokens) async { - final rankBySymbol = {}; - final iconBySymbol = {}; - try { - final response = await _client.get(Uri.parse(_coinGeckoTopUrl)); - if (response.statusCode < 200 || response.statusCode >= 300) { - throw SwapApiException(response.statusCode, response.body); - } - final payload = jsonDecode(response.body) as List; - for (var i = 0; i < payload.length; i++) { - final item = payload[i] as Map; - final symbol = (item['symbol'] as String).toUpperCase(); - if (rankBySymbol.containsKey(symbol)) continue; - rankBySymbol[symbol] = i; - final icon = item['image'] as String?; - if (icon != null && icon.isNotEmpty) iconBySymbol[symbol] = icon; - } - } catch (e) { - quantusPrint('CoinGecko ranking failed, sorting swap tokens by price: $e'); - } - final ranked = [ - for (final token in tokens) - token.copyWith(iconUrl: iconBySymbol[token.symbol] ?? _fallbackTokenIconUrl(token.symbol)), - ]; - ranked.sort((a, b) { - final ar = rankBySymbol[a.symbol] ?? 99999; - final br = rankBySymbol[b.symbol] ?? 99999; - if (ar != br) return ar.compareTo(br); - return b.usdPrice.compareTo(a.usdPrice); - }); - return ranked; - } - int _networkPriority(String network) { switch (network) { case 'ETH': @@ -401,52 +361,6 @@ class SwapService { } } - String? _fallbackTokenIconUrl(String symbol) { - switch (symbol) { - case 'USDC': - return 'https://assets.coingecko.com/coins/images/6319/large/usdc.png'; - case 'USDT': - return 'https://assets.coingecko.com/coins/images/325/large/Tether.png'; - case 'ETH': - case 'WETH': - return 'https://assets.coingecko.com/coins/images/279/large/ethereum.png'; - case 'BTC': - case 'WBTC': - case 'XBTC': - return 'https://assets.coingecko.com/coins/images/1/large/bitcoin.png'; - case 'SOL': - return 'https://assets.coingecko.com/coins/images/4128/large/solana.png'; - case 'NEAR': - case 'WNEAR': - return 'https://assets.coingecko.com/coins/images/10365/large/near.jpg'; - default: - return null; - } - } - - String? _networkIconUrl(String network) { - switch (network) { - case 'ETH': - case 'BASE': - case 'ARB': - case 'OP': - case 'GNOSIS': - case 'AVAX': - case 'POL': - case 'MONAD': - case 'BSC': - return 'https://assets.coingecko.com/coins/images/279/large/ethereum.png'; - case 'BTC': - return 'https://assets.coingecko.com/coins/images/1/large/bitcoin.png'; - case 'SOL': - return 'https://assets.coingecko.com/coins/images/4128/large/solana.png'; - case 'NEAR': - return 'https://assets.coingecko.com/coins/images/10365/large/near.jpg'; - default: - return null; - } - } - /// Remembers [address] on [network] for future swaps, most recent first. Future saveAddress(String network, String address) async { final prefs = await SharedPreferences.getInstance(); diff --git a/quantus_sdk/test/services/swap_service_test.dart b/quantus_sdk/test/services/swap_service_test.dart index d304bed7a..2bfc3f58e 100644 --- a/quantus_sdk/test/services/swap_service_test.dart +++ b/quantus_sdk/test/services/swap_service_test.dart @@ -461,7 +461,7 @@ void main() { isQuantus: true, ); expect(quantus.networkName, 'Quantus'); - expect(quantus.copyWith(iconUrl: 'x').isQuantus, isTrue); + expect(quantus.isQuantus, isTrue); }); }); @@ -596,10 +596,7 @@ void main() { ]; SwapService listing(List> list, {String? quantusAssetId, bool preflight = false}) => _service( - (r) async { - if (r.url.host == 'api.coingecko.com') return http.Response('down', 503); - return http.Response(jsonEncode(list), 200); - }, + (r) async => http.Response(jsonEncode(list), 200), quantusAssetId: quantusAssetId, preflight: preflight, ); @@ -615,24 +612,17 @@ void main() { expect(quantus.networkName, 'Quantus'); expect(quantus.decimals, AppConstants.decimals); expect(quantus.usdPrice, 1.5); - expect((await service.getFromTokens()).map((t) => t.symbol), isNot(contains('QTC'))); + final listed = await service.getFromTokens(); + expect(listed.first, quantus); + expect(listed.where((t) => t.symbol == 'QTC'), [quantus]); }, ); - test('the listed QTC costs the listing alone, never CoinGecko', () async { - final service = _service((r) async { - if (r.url.host == 'api.coingecko.com') fail('CoinGecko was asked for the price of QTC'); - return http.Response(jsonEncode(tokens), 200); - }); - expect((await service.getListedQuantusToken())!.usdPrice, 1.5); - }); - test('revalidates the listing by its ETag and keeps the body on a 304', () async { final sent = []; var price = 1.5; var version = 'W/"v1"'; final service = _service((r) async { - if (r.url.host == 'api.coingecko.com') return http.Response('down', 503); sent.add(r.headers['If-None-Match']); if (r.headers['If-None-Match'] == version) return http.Response('', 304); final body = [ @@ -664,7 +654,9 @@ void main() { expect(quantus.isQuantus, isTrue); expect(quantus.network, SwapToken.quantusNetwork); expect(quantus.usdPrice, 2); - expect((await service.getFromTokens()).map((t) => t.assetId), isNot(contains(listedId))); + final listed = await service.getFromTokens(); + expect(listed.first.assetId, listedId); + expect(listed.skip(1).map((t) => t.assetId), isNot(contains(listedId))); }); test('a configured asset id picks QTC out of several tokens on Quantus', () async { @@ -722,7 +714,9 @@ void main() { expect(standIn.symbol, 'WNEAR'); expect(standIn.network, 'NEAR'); expect(standIn.decimals, 24); - expect((await service.getFromTokens()).map((t) => t.symbol), ['BTC', 'USDC']); + final listed = await service.getFromTokens(); + expect(listed.first, standIn); + expect(listed.map((t) => t.symbol), ['WNEAR', 'USDC', 'BTC']); }); test('a QTC listing without a price leaves swaps unavailable', () async { @@ -732,44 +726,32 @@ void main() { expect(await service.getListedQuantusToken(), isNull); }); - test('keeps one asset per symbol, preferring the main network, ranked by CoinGecko', () async { + test('lists QTC first, USDC second and the rest as 1Click orders them, one asset per symbol', () async { final service = _service((r) async { - if (r.url.host == 'api.coingecko.com') { - return http.Response( - jsonEncode([ - {'symbol': 'btc', 'image': 'https://img/btc.png'}, - {'symbol': 'usdc', 'image': 'https://img/usdc.png'}, - ]), - 200, - ); - } expect(r.url.toString(), 'https://oneclick.test/v0/tokens'); return http.Response(jsonEncode(tokens), 200); }); final result = await service.getFromTokens(); - expect(result.map((t) => t.symbol), ['BTC', 'USDC', 'WNEAR']); + expect(result.map((t) => t.symbol), ['QTC', 'USDC', 'WNEAR', 'BTC']); + expect(result.first.isQuantus, isTrue); final usdc = result[1]; expect(usdc.assetId, _usdcEth.assetId); expect(usdc.network, 'ETH'); expect(usdc.decimals, 6); expect(usdc.usdPrice, 0.99966); - expect(usdc.iconUrl, 'https://img/usdc.png'); - expect(usdc.networkIconUrl, isNotNull); - expect(result[2].iconUrl, contains('near')); }); - test('falls back to price order when CoinGecko is down, and caches', () async { + test("keeps 1Click's order when USDC is not listed, and caches the listing", () async { var tokenCalls = 0; final service = _service((r) async { - if (r.url.host == 'api.coingecko.com') return http.Response('down', 503); tokenCalls++; - return http.Response(jsonEncode(tokens), 200); + return http.Response(jsonEncode(tokens.where((t) => t['symbol'] != 'USDC').toList()), 200); }); - expect((await service.getFromTokens()).map((t) => t.symbol), ['BTC', 'WNEAR', 'USDC']); - expect((await service.getFromTokens(limit: 2)).map((t) => t.symbol), ['BTC', 'WNEAR']); + expect((await service.getFromTokens()).map((t) => t.symbol), ['QTC', 'WNEAR', 'BTC']); + expect((await service.getFromTokens(limit: 2)).map((t) => t.symbol), ['QTC', 'WNEAR']); expect(tokenCalls, 1); await service.getFromTokens(forceRefresh: true); expect(tokenCalls, 2); From d14f9563a575bae27bf5df9ea926ee0cbdb01db0 Mon Sep 17 00:00:00 2001 From: Nikolaus Heger Date: Fri, 9 Oct 2026 11:11:32 +0800 Subject: [PATCH 4/4] feature so we can test swap on testflight and internal testing --- mobile-app/build_defines.sh | 12 ++++++++++++ mobile-app/lib/shared/utils/build_label.dart | 5 +++++ .../v2/screens/settings/about_quantus_screen.dart | 3 ++- .../lib/v2/screens/settings/settings_screen.dart | 3 ++- mobile-app/upload_android.sh | 7 ++++++- mobile-app/upload_ios_build.sh | 3 ++- quantus_sdk/lib/src/constants/app_constants.dart | 8 ++++---- quantus_sdk/lib/src/models/remote_config_model.dart | 7 +++---- 8 files changed, 36 insertions(+), 12 deletions(-) create mode 100644 mobile-app/build_defines.sh create mode 100644 mobile-app/lib/shared/utils/build_label.dart diff --git a/mobile-app/build_defines.sh b/mobile-app/build_defines.sh new file mode 100644 index 000000000..15d4f5dd1 --- /dev/null +++ b/mobile-app/build_defines.sh @@ -0,0 +1,12 @@ +# Sourced by the upload scripts. --swap-test: internal test build that offers swap regardless of flags and location; +# never ship it to a store's production track. +DEFINES="" +[ $# -le 1 ] || { echo "Too many options: $* (only --swap-test is supported)" >&2; exit 1; } +case "${1:-}" in + "") ;; + --swap-test) + DEFINES="--dart-define=SWAP_ALLOW_OVERRIDE=true" + echo "*** SWAP TEST BUILD: swap is on regardless of remote flags and location. Internal testing only. ***" + ;; + *) echo "Unknown option: $1 (only --swap-test is supported)" >&2; exit 1 ;; +esac diff --git a/mobile-app/lib/shared/utils/build_label.dart b/mobile-app/lib/shared/utils/build_label.dart new file mode 100644 index 000000000..ea2259da3 --- /dev/null +++ b/mobile-app/lib/shared/utils/build_label.dart @@ -0,0 +1,5 @@ +import 'package:quantus_sdk/quantus_sdk.dart'; +import 'package:resonance_network_wallet/generated/version.g.dart'; + +/// The build number as settings shows it; a swap test build says so. +const shownBuildNumber = AppConstants.swapAllowOverride ? '$appBuildNumber swap-test' : appBuildNumber; diff --git a/mobile-app/lib/v2/screens/settings/about_quantus_screen.dart b/mobile-app/lib/v2/screens/settings/about_quantus_screen.dart index 4c1ba6420..3d25691c6 100644 --- a/mobile-app/lib/v2/screens/settings/about_quantus_screen.dart +++ b/mobile-app/lib/v2/screens/settings/about_quantus_screen.dart @@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:quantus_sdk/quantus_sdk.dart' hide ScaffoldBase; import 'package:resonance_network_wallet/v2/components/scaffold_base.dart'; import 'package:resonance_network_wallet/generated/version.g.dart'; +import 'package:resonance_network_wallet/shared/utils/build_label.dart'; import 'package:resonance_network_wallet/l10n/app_localizations.dart'; import 'package:resonance_network_wallet/providers/l10n_provider.dart'; import 'package:resonance_network_wallet/shared/utils/open_external_url.dart'; @@ -62,7 +63,7 @@ class AboutQuantusScreenV2 extends ConsumerWidget { Image.asset('assets/v2/quantus_orange_logo.png', height: 40), const SizedBox(height: 14), Text( - l10n.settingsAboutVersion(appVersion, appBuildNumber), + l10n.settingsAboutVersion(appVersion, shownBuildNumber), textAlign: TextAlign.center, style: text.caption.copyWith(color: colors.textMuted), ), diff --git a/mobile-app/lib/v2/screens/settings/settings_screen.dart b/mobile-app/lib/v2/screens/settings/settings_screen.dart index f005c6373..f75e7bb62 100644 --- a/mobile-app/lib/v2/screens/settings/settings_screen.dart +++ b/mobile-app/lib/v2/screens/settings/settings_screen.dart @@ -4,6 +4,7 @@ import 'package:flutter_svg/flutter_svg.dart'; import 'package:quantus_sdk/quantus_sdk.dart' hide ScaffoldBase; import 'package:resonance_network_wallet/v2/components/scaffold_base.dart'; import 'package:resonance_network_wallet/generated/version.g.dart'; +import 'package:resonance_network_wallet/shared/utils/build_label.dart'; import 'package:resonance_network_wallet/l10n/app_localizations.dart'; import 'package:resonance_network_wallet/providers/l10n_provider.dart'; import 'package:resonance_network_wallet/shared/constants/e2e_keys.dart'; @@ -122,7 +123,7 @@ List<_SettingsHubItem> _settingsHubItems(BuildContext context, AppLocalizations ), ), title: l10n.settingsAboutTitle, - subtitle: l10n.settingsAboutHubSubtitle(appVersion, appBuildNumber), + subtitle: l10n.settingsAboutHubSubtitle(appVersion, shownBuildNumber), page: const AboutQuantusScreenV2(), ), ]; diff --git a/mobile-app/upload_android.sh b/mobile-app/upload_android.sh index 3ed54da53..a66501f8b 100755 --- a/mobile-app/upload_android.sh +++ b/mobile-app/upload_android.sh @@ -1,5 +1,10 @@ #!/bin/sh -flutter build appbundle +set -eu + +cd "$(dirname "$0")" +. ./build_defines.sh + +flutter build appbundle $DEFINES open build/app/outputs/bundle/release diff --git a/mobile-app/upload_ios_build.sh b/mobile-app/upload_ios_build.sh index df92715e4..f2d5f079c 100755 --- a/mobile-app/upload_ios_build.sh +++ b/mobile-app/upload_ios_build.sh @@ -5,6 +5,7 @@ set -eu cd "$(dirname "$0")" +. ./build_defines.sh # Ensure Xcode export uses Apple's rsync implementation. # In case user installed custom rsync in homebrew for example @@ -16,7 +17,7 @@ echo "Cleaning build folder" rm -rf build echo "Building the app" -flutter build ipa --release +flutter build ipa --release $DEFINES echo "Opening Transporter" open -a "Transporter" "build/ios/ipa/Quantus.ipa" \ No newline at end of file diff --git a/quantus_sdk/lib/src/constants/app_constants.dart b/quantus_sdk/lib/src/constants/app_constants.dart index ac9a20cad..4881cea5f 100644 --- a/quantus_sdk/lib/src/constants/app_constants.dart +++ b/quantus_sdk/lib/src/constants/app_constants.dart @@ -54,10 +54,10 @@ class AppConstants { static const String swapPreflightAddress = String.fromEnvironment('SWAP_PREFLIGHT_ADDRESS'); static const bool swapPreflight = swapPreflightAssetId != ''; - // Debug builds only: skips the NEAR Intents location verdict so swap can be - // tried from anywhere. - // flutter run --dart-define=SWAP_IGNORE_GEO=true - static const bool swapIgnoreGeo = bool.fromEnvironment('SWAP_IGNORE_GEO'); + // Internal test builds only (TestFlight, Play internal track): offers swap + // whatever the remote flags and the location say. Never ship one to a store. + // mobile-app/upload_ios_build.sh --swap-test (or upload_android.sh) + static const bool swapAllowOverride = bool.fromEnvironment('SWAP_ALLOW_OVERRIDE'); // internal group URL is this (note the /c) // https://t.me/c/quantusnetwork/2457 diff --git a/quantus_sdk/lib/src/models/remote_config_model.dart b/quantus_sdk/lib/src/models/remote_config_model.dart index 6e9e5a177..2eb1c2f5f 100644 --- a/quantus_sdk/lib/src/models/remote_config_model.dart +++ b/quantus_sdk/lib/src/models/remote_config_model.dart @@ -47,11 +47,10 @@ class RemoteConfigModel { }); /// Swap is offered when the platform's flag is on and the location allows - /// NEAR Intents; a debug build started with [AppConstants.swapIgnoreGeo] - /// skips the location. + /// NEAR Intents, or always in a [AppConstants.swapAllowOverride] build. bool get swapAvailable => - (defaultTargetPlatform == TargetPlatform.android ? enableSwapAndroid : enableSwap) && - (geoNearAllowed || (kDebugMode && AppConstants.swapIgnoreGeo)); + AppConstants.swapAllowOverride || + ((defaultTargetPlatform == TargetPlatform.android ? enableSwapAndroid : enableSwap) && geoNearAllowed); RemoteConfigModel copyWith({bool? geoNearAllowed}) => RemoteConfigModel( enableTestButtons: enableTestButtons,