CW-1130-deuro-fixes-improvements (#2392)

* feat: add support for Ethereum EIP-681 URIs in PaymentRequest and implement test coverage

* test: add unit test for EIP-681 URI handling with contract and no chainId [skip-ci]

* fix(CW-1114): rounding error for really large numbers

* fix: wrong decimals for eth fee display

* fix: limit fractional digits to a maximum of `decimals` in `format_fixed` function [skip-ci]

* test: add unit tests for `formatFixed` function

* refactor: replace custom balance formatting with `formatFixed` utility function

* refactor: improve dEuroViewModel formatting and computation logic

* refactor: extract and simplify interest action handlers in dEuro savings page

* fix: deuro add should show edit Add bottom sheet

* feat: block user from entering amount bigger than the max

* feat: use new tooltip bottomsheet for dEuro

* feat: add pull down to refresh dEuro savings

---------

Co-authored-by: Omar Hatem <omarh.ismail1@gmail.com>
This commit is contained in:
Konstantin Ullrich
2025-07-19 01:47:06 +03:00
committed by GitHub
co-authored by Omar Hatem
parent b6a7dde8e4
commit 43f2225e0c
5 changed files with 221 additions and 149 deletions
+3 -11
View File
@@ -1,6 +1,5 @@
import 'dart:convert';
import 'dart:math';
import 'package:intl/intl.dart';
import 'package:cw_core/format_fixed.dart';
import 'package:cw_core/balance.dart';
@@ -12,17 +11,10 @@ class EVMChainERC20Balance extends Balance {
final int exponent;
@override
String get formattedAdditionalBalance => _balance();
String get formattedAdditionalBalance => formatFixed(balance, exponent, fractionalDigits: 12);
@override
String get formattedAvailableBalance => _balance();
String _balance() {
NumberFormat formatter = NumberFormat('0.00##########', 'en_US');
double numBalance = (balance / BigInt.from(10).pow(exponent)).toDouble();
String formattedBalance = formatter.format(numBalance);
return formattedBalance;
}
String get formattedAvailableBalance => formatFixed(balance, exponent, fractionalDigits: 12);
String toJSON() => json.encode({
'balanceInWei': balance.toString(),
@@ -9,7 +9,9 @@ import 'package:cake_wallet/src/widgets/alert_with_one_action.dart';
import 'package:cake_wallet/src/widgets/bottom_sheet/base_bottom_sheet_widget.dart';
import 'package:cake_wallet/src/widgets/bottom_sheet/confirm_sending_bottom_sheet_widget.dart';
import 'package:cake_wallet/src/widgets/bottom_sheet/info_bottom_sheet_widget.dart';
import 'package:cake_wallet/src/widgets/bottom_sheet/tooltip_bottom_sheet.dart';
import 'package:cake_wallet/src/widgets/gradient_background.dart';
import 'package:cake_wallet/utils/responsive_layout_util.dart';
import 'package:cake_wallet/utils/show_pop_up.dart';
import 'package:cake_wallet/view_model/integrations/deuro_view_model.dart';
import 'package:cake_wallet/view_model/send/send_view_model_state.dart';
@@ -37,86 +39,71 @@ class DEuroSavingsPage extends BasePage {
@override
String get title => S.current.deuro_savings;
Widget trailing(BuildContext context) => MergeSemantics(
child: SizedBox(
height: 37,
width: 37,
child: ButtonTheme(
minWidth: double.minPositive,
child: Semantics(
label: "Refresh",
child: TextButton(
style: TextButton.styleFrom(
foregroundColor: Theme.of(context).colorScheme.onSurface,
overlayColor: WidgetStateColor.resolveWith((states) => Colors.transparent),
),
onPressed: _dEuroViewModel.reloadSavingsUserData,
child: Icon(
Icons.refresh,
color: pageIconColor(context),
size: 20,
),
),
),
),
),
);
@override
Widget body(BuildContext context) {
WidgetsBinding.instance.addPostFrameCallback((_) => _setReactions(context, _dEuroViewModel));
return Container(
width: double.infinity,
child: Column(
children: <Widget>[
Observer(
builder: (_) => SavingsCard(
isDarkTheme: currentTheme.isDark,
interestRate: "${_dEuroViewModel.interestRate}%",
savingsBalance: _dEuroViewModel.savingsBalance,
fiatSavingsBalance: _dEuroViewModel.fiatSavingsBalance,
currency: CryptoCurrency.deuro,
fiatCurrency: _dEuroViewModel.isFiatDisabled ? null : _dEuroViewModel.fiat,
onAddSavingsPressed: () => _onSavingsAdd(context),
onRemoveSavingsPressed: () => _onSavingsRemove(context),
onApproveSavingsPressed: _dEuroViewModel.prepareApproval,
onTooltipPressed: () => _onSavingsTooltipPressed(context),
isEnabled: _dEuroViewModel.isEnabled,
isLoading: _dEuroViewModel.isLoading,
),
),
Observer(
builder: (_) => InterestCardWidget(
isDarkTheme: currentTheme.isDark,
title: S.of(context).deuro_savings_collect_interest,
fiatAccruedInterest: _dEuroViewModel.fiatAccruedInterest,
fiatCurrency: _dEuroViewModel.isFiatDisabled ? null : _dEuroViewModel.fiat,
accruedInterest: _dEuroViewModel.accruedInterest,
onCollectInterest: _dEuroViewModel.prepareCollectInterest,
onReinvestInterest: _dEuroViewModel.prepareReinvestInterest,
onTooltipPressed: () => _onInterestTooltipPressed(context),
isEnabled: _dEuroViewModel.isEnabled,
),
),
Spacer(),
SafeArea(
child: Padding(
padding: EdgeInsets.only(bottom: 30),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
InfoChip(
label: S.of(context).deuro_about_deuro,
icon: CupertinoIcons.info,
onPressed: () => _showWelcomeTooltip(context),
return RefreshIndicator(
displacement: responsiveLayoutUtil.screenHeight * 0.1,
onRefresh: _dEuroViewModel.reloadSavingsUserData,
child: CustomScrollView(
slivers: <Widget>[
SliverFillRemaining(
child: SizedBox(
width: double.infinity,
child: Column(
children: <Widget>[
Observer(
builder: (_) => SavingsCard(
isDarkTheme: currentTheme.isDark,
interestRate: "${_dEuroViewModel.interestRateFormated}%",
savingsBalance: _dEuroViewModel.savingsBalanceFormated,
fiatSavingsBalance: _dEuroViewModel.fiatSavingsBalanceFormated,
currency: CryptoCurrency.deuro,
fiatCurrency: _dEuroViewModel.isFiatDisabled ? null : _dEuroViewModel.fiat,
onAddSavingsPressed: () => _onSavingsAdd(context),
onRemoveSavingsPressed: () => _onSavingsRemove(context),
onApproveSavingsPressed: _dEuroViewModel.prepareApproval,
onTooltipPressed: () => _onSavingsTooltipPressed(context),
isEnabled: _dEuroViewModel.isEnabled,
isLoading: _dEuroViewModel.isLoading,
),
),
Observer(
builder: (_) => InterestCardWidget(
isDarkTheme: currentTheme.isDark,
title: S.of(context).deuro_savings_collect_interest,
fiatAccruedInterest: _dEuroViewModel.fiatAccruedInterestFormated,
fiatCurrency: _dEuroViewModel.isFiatDisabled ? null : _dEuroViewModel.fiat,
accruedInterest: _dEuroViewModel.accruedInterestFormated,
onCollectInterest: _onCollectInterest,
onReinvestInterest: _onReinvestInterest,
onTooltipPressed: () => _onInterestTooltipPressed(context),
isEnabled: _dEuroViewModel.isSavingsActionsEnabled,
),
),
Spacer(),
SafeArea(
child: Padding(
padding: EdgeInsets.only(bottom: 30),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
InfoChip(
label: S.of(context).deuro_about_deuro,
icon: CupertinoIcons.info,
onPressed: () => _showWelcomeTooltip(context),
),
SizedBox(width: 10),
InfoChip(
label: S.of(context).website,
icon: CupertinoIcons.globe,
onPressed: () => launchUrlString("https://deuro.com/"),
)
],
),
),
),
SizedBox(width: 10),
InfoChip(
label: S.of(context).website,
icon: CupertinoIcons.globe,
onPressed: () => launchUrlString("https://deuro.com/"),
)
],
),
),
@@ -126,14 +113,36 @@ class DEuroSavingsPage extends BasePage {
);
}
bool _editSheetIsOpen = false;
Future<void> _onSavingsAdd(BuildContext context) async {
if (_editSheetIsOpen) return;
_editSheetIsOpen = true;
final amount = await _showEditBottomSheet(context, isAdding: true);
if (amount != null) _dEuroViewModel.prepareSavingsEdit(amount, true);
_editSheetIsOpen = false;
}
Future<void> _onSavingsRemove(BuildContext context) async {
if (_editSheetIsOpen) return;
_editSheetIsOpen = true;
final amount = await _showEditBottomSheet(context, isAdding: false);
if (amount != null) _dEuroViewModel.prepareSavingsEdit(amount, false);
_editSheetIsOpen = false;
}
Future<void> _onReinvestInterest() async {
if (_editSheetIsOpen) return;
_editSheetIsOpen = true;
await _dEuroViewModel.prepareReinvestInterest();
_editSheetIsOpen = false;
}
Future<void> _onCollectInterest() async {
if (_editSheetIsOpen) return;
_editSheetIsOpen = true;
await _dEuroViewModel.prepareCollectInterest();
_editSheetIsOpen = false;
}
bool _isReactionsSet = false;
@@ -175,10 +184,10 @@ class DEuroSavingsPage extends BasePage {
currency: CryptoCurrency.deuro,
amount: S.of(bottomSheetContext).send_amount,
amountValue: _dEuroViewModel.actionType == DEuroActionType.reinvest
? _dEuroViewModel.accruedInterest
? _dEuroViewModel.accruedInterestFormated
: tx.amountFormatted,
fiatAmountValue: _dEuroViewModel.actionType == DEuroActionType.reinvest
? _dEuroViewModel.fiatAccruedInterest
? _dEuroViewModel.fiatAccruedInterestFormated
: _dEuroViewModel.pendingTransactionFiatAmountFormatted,
fee: S.of(bottomSheetContext).send_estimated_fee,
feeValue: tx.feeFormatted,
@@ -301,11 +310,11 @@ class DEuroSavingsPage extends BasePage {
showModalBottomSheet<void>(
context: context,
builder: (BuildContext bottomSheetContext) => InfoBottomSheet(
builder: (BuildContext bottomSheetContext) => TooltipSheet(
currentTheme: currentTheme,
titleText: title,
titleIconPath: CryptoCurrency.deuro.iconPath,
content: content,
tooltip: content,
footerType: FooterType.doubleActionButton,
doubleActionRightButtonText: S.of(context).close,
rightActionButtonKey: ValueKey('deuro_page_tooltip_dialog_${key}_ok_button_key'),
@@ -358,7 +367,8 @@ class DEuroSavingsPage extends BasePage {
content: S.of(context).deuro_tooltip_no_eth,
singleActionButtonKey: ValueKey('deuro_page_tooltip_dialog_no_eth_ok_button_key'),
singleActionButtonText: S.of(context).close,
onSingleActionButtonPressed: () => Navigator.of(bottomSheetContext).pop(), footerType: FooterType.singleActionButton,
onSingleActionButtonPressed: () => Navigator.of(bottomSheetContext).pop(),
footerType: FooterType.singleActionButton,
),
);
}
@@ -375,7 +385,9 @@ class DEuroSavingsPage extends BasePage {
balanceTitle: isAdding
? S.of(context).deuro_savings_available_to_add
: S.of(context).deuro_savings_available_to_remove,
balance: isAdding ? _dEuroViewModel.accountBalance : _dEuroViewModel.savingsBalance,
balance: isAdding
? _dEuroViewModel.accountBalanceFormated
: _dEuroViewModel.savingsBalanceFormated,
footerType: FooterType.none,
maxHeight: MediaQuery.of(context).size.height * 0.8,
),
@@ -3,6 +3,7 @@ import 'package:cake_wallet/generated/i18n.dart';
import 'package:cake_wallet/src/screens/integrations/deuro/widgets/numpad.dart';
import 'package:cake_wallet/src/widgets/bottom_sheet/base_bottom_sheet_widget.dart';
import 'package:cake_wallet/src/widgets/primary_button.dart';
import 'package:cw_core/parse_fixed.dart';
import 'package:flutter/material.dart';
class SavingsEditSheet extends BaseBottomSheet {
@@ -14,7 +15,8 @@ class SavingsEditSheet extends BaseBottomSheet {
super.titleIconPath,
this.balance,
this.balanceTitle,
required super.footerType, required super.maxHeight,
required super.footerType,
required super.maxHeight,
});
@override
@@ -53,8 +55,35 @@ class _SavingsEditBodyState extends State<_SavingsEditBody> {
}
String amount = '0';
bool isValid = false;
final FocusNode _numpadFocusNode = FocusNode();
void _onPressedAll() => setState(() {
amount = widget.balance!;
isValid = _validate();
});
void _onNumberPressed(int i) => setState(() {
amount = amount == '0' ? i.toString() : '${amount}${i}';
isValid = _validate();
});
void _onDeletePressed() => setState(() {
amount = amount.length > 1 ? amount.substring(0, amount.length - 1) : '0';
isValid = _validate();
});
void _onDecimalPressed() => setState(() {
amount = '${amount.replaceAll('.', '')}.';
isValid = _validate();
});
bool _validate() {
final amountBigInt = parseFixed(amount, 18);
final balanceBigInt = parseFixed(widget.balance!, 18);
return balanceBigInt >= amountBigInt && amountBigInt > BigInt.zero;
}
@override
Widget build(BuildContext context) => SafeArea(
child: Column(children: [
@@ -80,18 +109,14 @@ class _SavingsEditBodyState extends State<_SavingsEditBody> {
AssetBalanceRow(
title: widget.balanceTitle!,
amount: widget.balance!,
onAllPressed: () => setState(() => amount = widget.balance!),
onAllPressed: _onPressedAll,
),
],
NumberPad(
focusNode: _numpadFocusNode,
onNumberPressed: (i) => setState(
() => amount = amount == '0' ? i.toString() : '${amount}${i}',
),
onDeletePressed: () => setState(
() => amount = amount.length > 1 ? amount.substring(0, amount.length - 1) : '0',
),
onDecimalPressed: () => setState(() => amount = '${amount.replaceAll('.', '')}.'),
onNumberPressed: _onNumberPressed,
onDeletePressed: _onDeletePressed,
onDecimalPressed: _onDecimalPressed,
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
@@ -101,7 +126,7 @@ class _SavingsEditBodyState extends State<_SavingsEditBody> {
color: Theme.of(context).colorScheme.primary,
textColor: Theme.of(context).colorScheme.onPrimary,
isLoading: false,
isDisabled: false,
isDisabled: !isValid,
),
)
]),
@@ -0,0 +1,46 @@
import 'package:auto_size_text/auto_size_text.dart';
import 'package:cake_wallet/src/widgets/bottom_sheet/base_bottom_sheet_widget.dart';
import 'package:flutter/material.dart';
class TooltipSheet extends BaseBottomSheet {
final String tooltip;
const TooltipSheet({
required super.currentTheme,
required super.titleText,
super.titleIconPath,
required this.tooltip,
required super.footerType,
super.maxHeight = 900,
super.singleActionButtonText,
super.onSingleActionButtonPressed,
super.singleActionButtonKey,
super.doubleActionLeftButtonText,
super.doubleActionRightButtonText,
super.onLeftActionButtonPressed,
super.onRightActionButtonPressed,
super.leftActionButtonKey,
super.rightActionButtonKey,
});
@override
Widget contentWidget(BuildContext context) => Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10),
color: Theme.of(context).colorScheme.surfaceContainer),
margin: const EdgeInsets.all(12),
padding: const EdgeInsets.all(12),
child: AutoSizeText(
tooltip,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium!.copyWith(
fontSize: 16,
fontWeight: FontWeight.w500,
color: Theme.of(context).colorScheme.onSurfaceVariant,
decoration: TextDecoration.none,
),
),
);
Widget footerWidget(BuildContext context) => SizedBox.shrink();
}
@@ -21,6 +21,8 @@ class DEuroViewModel = DEuroViewModelBase with _$DEuroViewModel;
abstract class DEuroViewModelBase with Store {
final AppStore _appStore;
static BigInt get MIN_ACCRUED_INTEREST => BigInt.parse("1000000000000");
DEuroViewModelBase(
this._appStore,
this.balanceViewModel,
@@ -44,39 +46,31 @@ abstract class DEuroViewModelBase with Store {
@action
void acceptDisclaimer() => _settingsStore.shouldShowDEuroDisclaimer = false;
@computed
String get pendingTransactionFiatAmountFormatted =>
isFiatDisabled ? '' : '$pendingTransactionFiatAmount ${fiat.title}';
@computed
String get pendingTransactionFeeFiatAmountFormatted =>
isFiatDisabled ? '' : '$pendingTransactionFeeFiatAmount ${fiat.title}';
FiatCurrency get fiat => _settingsStore.fiatCurrency;
@computed
String get pendingTransactionFiatAmount =>
transaction == null ? '0.00' : _getDEuroFiatAmount(transaction!.amountFormatted);
@computed
String get pendingTransactionFeeFiatAmount {
try {
if (transaction != null) {
final currency = CryptoCurrency.eth;
return calculateFiatAmount(
price: _fiatConversationStore.prices[currency]!,
cryptoAmount: transaction!.feeFormattedValue,
);
} else {
return '0.00';
}
} catch (_) {
return '0.00';
}
String get pendingTransactionFiatAmountFormatted {
final amount = transaction == null ? '0.00' : _getDEuroFiatAmount(transaction!.amountFormatted);
return isFiatDisabled ? '' : '$amount ${fiat.title}';
}
@computed
String get accountBalance {
String get pendingTransactionFeeFiatAmountFormatted {
var amount = '0.00';
try {
if (transaction != null) {
final feeCurrency = CryptoCurrency.eth;
amount = calculateFiatAmount(
price: _fiatConversationStore.prices[feeCurrency]!,
cryptoAmount: transaction!.feeFormattedValue,
);
}
} catch (_) {}
return isFiatDisabled ? '' : '$amount ${fiat.title}';
}
@computed
String get accountBalanceFormated {
final dEuroKey = balanceViewModel.balances.keys
.firstWhereOrNull((e) => e.title == CryptoCurrency.deuro.title);
if (dEuroKey == null) return '0.00';
@@ -84,22 +78,30 @@ abstract class DEuroViewModelBase with Store {
}
@observable
String savingsBalance = '0.00';
BigInt savingsBalance = BigInt.zero;
@computed
String get fiatSavingsBalance => _getDEuroFiatAmount(savingsBalance);
String get savingsBalanceFormated =>
ethereum!.formatterEthereumAmountToDouble(amount: savingsBalance).toStringAsFixed(6);
@computed
String get fiatSavingsBalanceFormated => _getDEuroFiatAmount(savingsBalanceFormated);
@observable
ExecutionState state = InitialExecutionState();
@observable
String interestRate = '0';
String interestRateFormated = '0';
@observable
String accruedInterest = '0.00';
BigInt accruedInterest = BigInt.zero;
@computed
String get fiatAccruedInterest => _getDEuroFiatAmount(accruedInterest);
String get accruedInterestFormated =>
ethereum!.formatterEthereumAmountToDouble(amount: accruedInterest).toStringAsFixed(6);
@computed
String get fiatAccruedInterestFormated => _getDEuroFiatAmount(accruedInterestFormated);
@observable
BigInt approvedTokens = BigInt.zero;
@@ -107,6 +109,9 @@ abstract class DEuroViewModelBase with Store {
@computed
bool get isEnabled => approvedTokens > BigInt.zero;
@computed
bool get isSavingsActionsEnabled => isEnabled && accruedInterest >= MIN_ACCRUED_INTEREST;
@observable
bool isLoading = true;
@@ -121,17 +126,9 @@ abstract class DEuroViewModelBase with Store {
@action
Future<void> reloadSavingsUserData() async {
final savingsBalanceRaw = ethereum!.getDEuroSavingsBalance(_appStore.wallet!);
final accruedInterestRaw = ethereum!.getDEuroAccruedInterest(_appStore.wallet!);
approvedTokens = await ethereum!.getDEuroSavingsApproved(_appStore.wallet!);
savingsBalance = ethereum!
.formatterEthereumAmountToDouble(amount: await savingsBalanceRaw)
.toStringAsFixed(6);
accruedInterest = ethereum!
.formatterEthereumAmountToDouble(amount: await accruedInterestRaw)
.toStringAsFixed(6);
savingsBalance = await ethereum!.getDEuroSavingsBalance(_appStore.wallet!);
accruedInterest = await ethereum!.getDEuroAccruedInterest(_appStore.wallet!);
isLoading = false;
}
@@ -139,7 +136,7 @@ abstract class DEuroViewModelBase with Store {
Future<void> reloadInterestRate() async {
final interestRateRaw = await ethereum!.getDEuroInterestRate(_appStore.wallet!);
interestRate = (interestRateRaw / BigInt.from(10000)).toString();
interestRateFormated = (interestRateRaw / BigInt.from(10000)).toString();
}
@action
@@ -175,7 +172,7 @@ abstract class DEuroViewModelBase with Store {
}
}
Future<void> prepareCollectInterest() => prepareSavingsEdit(accruedInterest, false);
Future<void> prepareCollectInterest() => prepareSavingsEdit(accruedInterestFormated, false);
Future<void> prepareReinvestInterest() async {
try {
@@ -230,13 +227,13 @@ abstract class DEuroViewModelBase with Store {
String _getDEuroFiatAmount(String amount) {
try {
CryptoCurrency deuro = CryptoCurrency.deuro;
var dEuro = CryptoCurrency.deuro;
final keys = _fiatConversationStore.prices.keys.toList();
for (final key in keys) {
if (key.title == "DEURO") deuro = key;
if (key.title == "DEURO") dEuro = key;
}
return calculateFiatAmount(
price: _fiatConversationStore.prices[deuro]!,
price: _fiatConversationStore.prices[dEuro]!,
cryptoAmount: amount,
);
} catch (_) {