Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 57 additions & 16 deletions mobile-app/lib/providers/currency_display_provider.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:convert';

import 'package:decimal/decimal.dart';
Expand All @@ -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
Expand Down Expand Up @@ -109,6 +111,38 @@ final exchangeRatesProvider = FutureProvider<Map<String, Decimal>>((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<Decimal>((ref) {
final swap = ref.watch(swapServiceProvider);
final prices = StreamController<Decimal>();

Future<void> 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
// ---------------------------------------------------------------------------
Expand All @@ -118,14 +152,19 @@ final exchangeRatesProvider = FutureProvider<Map<String, Decimal>>((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<ExchangeRateService>((ref) {
final ratesAsync = ref.watch(exchangeRatesProvider);
final settings = ref.read(settingsServiceProvider);
final tokenToUsdRate = ref.watch(tokenUsdPriceProvider).value;

ExchangeRateService service(Map<String, Decimal> 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)),
);
});

Expand Down Expand Up @@ -178,10 +217,12 @@ class SelectedFiatCurrencyNotifier extends StateNotifier<FiatCurrency> {
/// 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(
Expand Down Expand Up @@ -214,18 +255,17 @@ final balanceDisplayProvider = Provider<AsyncValue<CurrencyDisplayState>>((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,
),
),
);
});

Expand All @@ -244,7 +284,6 @@ typedef TxAmountFormatter =

final txAmountDisplayProvider = Provider<TxAmountFormatter>((ref) {
final selectedFiat = ref.watch(selectedFiatCurrencyProvider);
final xRate = ref.watch(exchangeRateServiceProvider);
final fmt = ref.watch(numberFormattingServiceProvider);
final localeConfig = ref.watch(localeNumberConfigProvider);

Expand All @@ -260,7 +299,6 @@ final txAmountDisplayProvider = Provider<TxAmountFormatter>((ref) {
CurrencyDisplayState data = _toFiatDisplayState(
amount,
selectedFiat,
xRate,
fmt,
tokenDecimals: tokenDecimals,
withTokenSymbol: withTokenSymbol,
Expand Down Expand Up @@ -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,
);
}
18 changes: 10 additions & 8 deletions mobile-app/lib/services/exchange_rate_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -23,11 +23,13 @@ class ExchangeRateService {
};

final Map<String, Decimal> _rates;
final Decimal tokenToUsdRate;
final Decimal? tokenToUsdRate;

ExchangeRateService({required Map<String, Decimal> rates, Decimal? tokenToUsdRate})
: _rates = rates,
tokenToUsdRate = tokenToUsdRate ?? Decimal.one;
ExchangeRateService({required Map<String, Decimal> 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) {
Expand All @@ -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));
}
Expand All @@ -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);
Expand Down
66 changes: 40 additions & 26 deletions mobile-app/lib/v2/components/amount_display_with_conversion.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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),
),
],
],
);
}
Expand Down
24 changes: 4 additions & 20 deletions mobile-app/lib/v2/components/token_icon.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -34,36 +32,22 @@ 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),
),
),
],
),
);
}

Widget _fallback(SwapToken token, AppColorsV3 colors, AppTextThemeV3 text) {
Widget _monogram(SwapToken token, AppColorsV3 colors, AppTextThemeV3 text) {
return Container(
decoration: BoxDecoration(
color: colors.bgSurface2,
Expand All @@ -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,
Expand Down
14 changes: 7 additions & 7 deletions mobile-app/lib/v2/screens/swap/swap_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ class _SwapScreenState extends ConsumerState<SwapScreen> {
final quantus = await service.getListedQuantusToken();
if (!mounted || load != _loads) return;
setState(() {
_foreign ??= tokens.first;
_foreign ??= tokens.firstWhere((t) => !t.isQuantus);
_quantus = quantus;
});
} catch (e) {
Expand All @@ -95,18 +95,18 @@ class _SwapScreenState extends ConsumerState<SwapScreen> {
_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<void> _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
Expand Down
2 changes: 1 addition & 1 deletion mobile-app/test/unit/amount_input_logic_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Expand Down
Loading
Loading