fix ui edge cases (#3109)
* wrap navbar in observer to account for disabling pages * always display fiat amount with 2 decimals on confirm sheet * don't show limit popup if max limit is 0 (this signifies the providers didn't load yet) * fix deposit amount getting set to "ALL" in race condition * disable showing hardware wallets on l2 wallet selector * fix reset in account customizer if designs weren't loaded properly * add withDecimals string extension * add fallback on failed display amount parse
malik1004x committed
Mar 18, 2026 at 22:34 UTC
24cceb5795b52469dc7e811d894e528ae17d7c5e
8 files changed
+155
-113
cw_core/lib/crypto_amount_format.dart
+20
@@ -17,6 +17,26 @@ extension MaxDecimals on String {
17
return parts.join(".");
18
}
19
20
+ String withDecimals(int decimals) {
21
+ var parts = split(".");
22
+
23
+ if (parts.length > 2) {
24
+ parts = [parts.first, parts.sublist(1).join("")];
25
+ }
26
+
27
+ if (parts.length == 1) {
28
+ parts.add("");
29
+ }
30
+
31
+ if (parts[1].length > decimals) {
32
+ parts[1] = parts[1].substring(0, decimals);
33
+ } else {
34
+ parts[1] = parts[1].padRight(decimals, '0');
35
+ }
36
+
37
+ return parts.join(".");
38
+ }
39
+
40
41
/// Format a stringified number to a localized representation
42
/// 1.000.000,00 in de_DE
lib/core/amount_parsing_proxy.dart
+12
-3
@@ -2,6 +2,7 @@ import 'package:cake_wallet/entities/bitcoin_amount_display_mode.dart';
2
import 'package:cake_wallet/src/screens/wallet_connect/utils/string_parsing.dart';
3
import 'package:cw_core/crypto_amount_format.dart';
4
import 'package:cw_core/crypto_currency.dart';
5
+import 'package:cw_core/utils/print_verbose.dart';
6
7
class AmountParsingProxy {
8
final BitcoinAmountDisplayMode displayMode;
@@ -19,11 +20,19 @@ class AmountParsingProxy {
20
21
/// [getCryptoOutputAmount] turns the input [amount] into the preferred representation of [cryptoCurrency]
22
String getDisplayCryptoAmount(String amount, CryptoCurrency cryptoCurrency) {
22
- if (useSatoshi(cryptoCurrency) && amount.isNotEmpty) {
23
- return cryptoCurrency.parseAmount(amount.withMaxDecimals(cryptoCurrency.decimals)).toString();
23
+
24
+ try {
25
+ if (useSatoshi(cryptoCurrency) && amount.isNotEmpty) {
26
+ return cryptoCurrency.parseAmount(amount.withMaxDecimals(cryptoCurrency.decimals)).toString();
27
+ }
28
+
29
+ return amount.withMaxDecimals(cryptoCurrency.decimals);
30
+
31
+ } catch(_) {
32
+ printV("failed to parse amount $amount for currency ${cryptoCurrency.title}, falling back to showing unparsed");
33
+ return amount;
34
}
35
26
- return amount.withMaxDecimals(cryptoCurrency.decimals);
36
}
37
38
/// [getCryptoStringRepresentation] turns the input [amount] into the preferred representation of [cryptoCurrency]
lib/new-ui/pages/account_customizer.dart
+4
-1
@@ -17,6 +17,7 @@ import 'package:cake_wallet/view_model/monero_account_list/account_list_item.dar
17
import 'package:cake_wallet/view_model/monero_account_list/monero_account_edit_or_create_view_model.dart';
18
import 'package:cake_wallet/view_model/monero_account_list/monero_account_list_view_model.dart';
19
import 'package:cw_core/balance_card_style_settings.dart';
20
+import 'package:cw_core/card_design.dart';
21
import 'package:cw_core/generate_name.dart';
22
import 'package:cw_core/sync_status.dart';
23
import 'package:cw_core/utils/print_verbose.dart';
@@ -381,7 +382,9 @@ class _AccountCustomizerState extends State<AccountCustomizer> {
382
selected: true,
383
designSwitchDuration: Duration(milliseconds: 200),
384
width: cardWidth,
384
- design: widget.dashboardViewModel.cardDesigns[i],
385
+ design: i >= widget.dashboardViewModel.cardDesigns.length
386
+ ? CardDesign.genericDefault
387
+ : widget.dashboardViewModel.cardDesigns[i],
388
),
389
order: i,
390
accountListItem: accounts[i]));
lib/new-ui/pages/swap_page.dart
+4
-1
@@ -292,7 +292,10 @@ class _NewSwapPageState extends State<NewSwapPage> {
292
293
_depositAmountDebounce.run(() {
294
widget.exchangeViewModel.calculateBestRate();
295
- widget.exchangeViewModel.changeDepositAmount(amount: depositAmountController.text);
295
+ if (depositAmountController.text != widget.exchangeViewModel.depositAmount &&
296
+ depositAmountController.text != S.of(context).all) {
297
+ widget.exchangeViewModel.changeDepositAmount(amount: depositAmountController.text);
298
+ }
299
widget.exchangeViewModel.isReceiveAmountEntered = false;
300
widget.exchangeViewModel.isFixedRateMode = false;
301
if (!receiveKey.currentState!.amountFocusNode.hasFocus) {
lib/new-ui/widgets/send_page/l2_action_wallet_selector.dart
+1
-1
@@ -57,7 +57,7 @@ class _L2ActionWalletSelectorState extends State<L2ActionWalletSelector> {
57
if (widget.showOtherWallets) {
58
() async {
59
items.addAll((await WalletInfo.getAll())
60
- .where((item) => item.type == widget.sendViewModel.walletType));
60
+ .where((item) => item.type == widget.sendViewModel.walletType && item.hardwareWalletType == null));
61
items.sort((a, b) {
62
if (a.name == widget.sendViewModel.wallet.name)
63
return -1;
lib/new-ui/widgets/send_page/send_confirm_sheet.dart
+6
-2
@@ -154,8 +154,10 @@ class SendTransactionDetails extends StatelessWidget {
154
String sumStr<T>(List<T> list, double Function(T) picker) =>
155
sumBy(list, picker).toString();
156
157
- String sumWithUnit<T>(List<T> list, double Function(T) picker, String unit) =>
158
- "${sumStr(list, picker)} $unit";
157
+ String sumWithUnit<T>(List<T> list, double Function(T) picker, String unit, {int? decimals}) {
158
+ final str = sumStr(list, picker);
159
+ return "${decimals == null ? str : str.withDecimals(decimals)} $unit";
160
+ }
161
162
163
Widget _buildMainContent(BuildContext context) {
@@ -198,6 +200,7 @@ class SendTransactionDetails extends StatelessWidget {
200
sendViewModel.outputs,
201
(o) => double.tryParse(o.fiatAmount.replaceAll(",", "")) ?? 0,
202
sendViewModel.fiatCurrency.title,
203
+ decimals:2
204
)
205
: sendViewModel.pendingTransactionFiatAmountFormatted;
206
@@ -206,6 +209,7 @@ class SendTransactionDetails extends StatelessWidget {
209
sendViewModel.outputs,
210
(o) => double.tryParse(o.estimatedFeeFiatAmount.replaceAll(",", "")) ?? 0,
211
sendViewModel.fiatCurrency.title,
212
+ decimals:2
213
)
214
: sendViewModel.pendingTransactionFeeFiatAmountFormatted;
215
lib/new-ui/widgets/swap_page/swap_limit_popup.dart
+1
-1
@@ -23,7 +23,7 @@ class SwapLimitPopup extends StatelessWidget {
23
final double? amount = double.tryParse(exchangeViewModel.depositAmountCanonical);
24
final max = exchangeViewModel.limits.max ?? double.infinity;
25
final min = exchangeViewModel.limits.min ?? 0;
26
- final tooLarge = amount != null && amount > max;
26
+ final tooLarge = amount != null && max != 0 && amount > max;
27
final tooSmall = amount != null && min != 0 && amount < min;
28
final show = amount != null && (tooLarge || tooSmall);
29
lib/src/screens/dashboard/widgets/new_main_navbar_widget.dart
+107
-104
@@ -2,6 +2,7 @@ import 'dart:io';
2
import 'dart:ui';
3
import 'package:cake_wallet/src/widgets/cake_image_widget.dart';
4
import 'package:flutter/material.dart';
5
+import 'package:flutter_mobx/flutter_mobx.dart';
6
import 'package:flutter_svg/flutter_svg.dart';
7
import 'package:cake_wallet/entities/new_main_actions.dart';
8
import 'package:cake_wallet/view_model/dashboard/dashboard_view_model.dart';
@@ -123,124 +124,126 @@ class _NEWNewMainNavBarState extends State<NewMainNavBar> {
124
final activeColor = theme.colorScheme.onSurface;
125
final inactiveColor = theme.colorScheme.primary;
126
126
- final visibleActions = NewMainActions.all
127
- .where(
128
- (action) => action.canShow?.call(widget.dashboardViewModel) ?? true)
129
- .toList();
130
-
131
- final pillWidth = _estimatePillWidthForAction(
132
- context, visibleActions[widget.selectedIndex],
133
- color: activeColor);
134
-
135
- final barWidth = calcBarWidth(pillWidth, visibleActions.length);
136
-
137
- final currentAction = visibleActions[widget.selectedIndex];
138
-
139
- return Align(
140
- alignment: Alignment.bottomCenter,
141
- child: SafeArea(
142
- bottom: !(Platform.isIOS),
143
- top: false,
144
- child: Padding(
145
- // tux PLEASE consult me (malik) before removing this padding.
146
- padding: EdgeInsets.only(bottom: NewMainNavBar.barBottomPadding),
147
- child: AnimatedContainer(
148
- duration: barResizeDuration,
149
- curve: Curves.easeOutCubic,
150
- width: barWidth,
151
- child: ClipRSuperellipse(
152
- borderRadius: BorderRadius.circular(barBorderRadius),
153
- child: BackdropFilter(
154
- filter: ImageFilter.blur(sigmaX: 3, sigmaY: 3),
155
- child: Container(
156
- height: NewMainNavBar.barHeight,
157
- decoration: ShapeDecoration(
158
- color: backgroundColor,
159
- shape: RoundedSuperellipseBorder(borderRadius: BorderRadius.circular(barBorderRadius),
160
- side: const BorderSide(color: Color(0x14FFFFFF), width: 1),
127
+ return Observer(
128
+ builder: (_) {
129
+ final visibleActions = NewMainActions.all
130
+ .where((action) => action.canShow?.call(widget.dashboardViewModel) ?? true)
131
+ .toList();
132
+
133
+ final pillWidth = _estimatePillWidthForAction(context, visibleActions[widget.selectedIndex],
134
+ color: activeColor);
135
+
136
+ final barWidth = calcBarWidth(pillWidth, visibleActions.length);
137
+
138
+ final currentAction = visibleActions[widget.selectedIndex];
139
+
140
+ return Align(
141
+ alignment: Alignment.bottomCenter,
142
+ child: SafeArea(
143
+ bottom: !(Platform.isIOS),
144
+ top: false,
145
+ child: Padding(
146
+ // tux PLEASE consult me (malik) before removing this padding.
147
+ padding: EdgeInsets.only(bottom: NewMainNavBar.barBottomPadding),
148
+ child: AnimatedContainer(
149
+ duration: barResizeDuration,
150
+ curve: Curves.easeOutCubic,
151
+ width: barWidth,
152
+ child: ClipRSuperellipse(
153
+ borderRadius: BorderRadius.circular(barBorderRadius),
154
+ child: BackdropFilter(
155
+ filter: ImageFilter.blur(sigmaX: 3, sigmaY: 3),
156
+ child: Container(
157
+ height: NewMainNavBar.barHeight,
158
+ decoration: ShapeDecoration(
159
+ color: backgroundColor,
160
+ shape: RoundedSuperellipseBorder(borderRadius: BorderRadius.circular(barBorderRadius),
161
+ side: const BorderSide(color: Color(0x14FFFFFF), width: 1),
162
+ ),
163
),
162
- ),
163
- child: Padding(
164
- padding: const EdgeInsets.symmetric(horizontal: barHorizontalPadding),
165
- child: Stack(
166
- alignment: Alignment.center,
167
- children: [
168
- AnimatedPill(
169
- left: calcLeft(widget.selectedIndex, pillWidth),
170
- pillColor: pillColor,
171
- currentAction: currentAction,
172
- pillIconHeight: pillIconHeight,
173
- pillIconWidth: pillIconWidth,
174
- pillIconSpacing: pillIconSpacing,
175
- pillBorderRadius: pillBorderRadius,
176
- contentColor: activeColor,
177
- estimateWidthForAction: pillWidth,
178
- pillTextStyle: pillTextStyle,
179
- pillMoveDuration: pillMoveDuration,
180
- pillResizeDuration: pillResizeDuration,
181
- ),
182
- for (int i = 0; i < visibleActions.length; i++)
183
- AnimatedPositioned(
184
- duration: pillResizeDuration,
185
- width: iconBoxWidth,
186
- left: calcLeft(i, pillWidth)+((i == widget.selectedIndex) ? iconHorizontalPadding/100 : 0),
187
- curve: Curves.easeOutCubic,
188
- child: InkWell(
189
- splashFactory: NoSplash.splashFactory,
190
- splashColor: Colors.transparent,
191
- borderRadius: BorderRadius.circular(pillBorderRadius),
192
- onTap: () => _onItemTap(i),
193
- child: AnimatedContainer(
194
- duration: _firstFrame
195
- ? Duration.zero
196
- : inactiveIconMoveDuration,
197
- curve: Curves.easeOutCubic,
198
- width:
199
- i == widget.selectedIndex ? pillWidth : iconBoxWidth,
200
- alignment: Alignment.center,
201
- child: AnimatedAlign(
202
- duration: inactiveIconFadeDuration,
164
+ child: Padding(
165
+ padding: const EdgeInsets.symmetric(horizontal: barHorizontalPadding),
166
+ child: Stack(
167
+ alignment: Alignment.center,
168
+ children: [
169
+ AnimatedPill(
170
+ left: calcLeft(widget.selectedIndex, pillWidth),
171
+ pillColor: pillColor,
172
+ currentAction: currentAction,
173
+ pillIconHeight: pillIconHeight,
174
+ pillIconWidth: pillIconWidth,
175
+ pillIconSpacing: pillIconSpacing,
176
+ pillBorderRadius: pillBorderRadius,
177
+ contentColor: activeColor,
178
+ estimateWidthForAction: pillWidth,
179
+ pillTextStyle: pillTextStyle,
180
+ pillMoveDuration: pillMoveDuration,
181
+ pillResizeDuration: pillResizeDuration,
182
+ ),
183
+ for (int i = 0; i < visibleActions.length; i++)
184
+ AnimatedPositioned(
185
+ duration: pillResizeDuration,
186
+ width: iconBoxWidth,
187
+ left: calcLeft(i, pillWidth)+((i == widget.selectedIndex) ? iconHorizontalPadding/100 : 0),
188
+ curve: Curves.easeOutCubic,
189
+ child: InkWell(
190
+ splashFactory: NoSplash.splashFactory,
191
+ splashColor: Colors.transparent,
192
+ borderRadius: BorderRadius.circular(pillBorderRadius),
193
+ onTap: () => _onItemTap(i),
194
+ child: AnimatedContainer(
195
+ duration: _firstFrame
196
+ ? Duration.zero
197
+ : inactiveIconMoveDuration,
198
curve: Curves.easeOutCubic,
199
+ width:
200
+ i == widget.selectedIndex ? pillWidth : iconBoxWidth,
201
alignment: Alignment.center,
205
- child: AnimatedScale(
206
- duration: inactiveIconAppearDuration,
202
+ child: AnimatedAlign(
203
+ duration: inactiveIconFadeDuration,
204
curve: Curves.easeOutCubic,
208
- scale: (i == widget.selectedIndex) ? 0.857 : 1.0,
209
- child: TweenAnimationBuilder<Color?>(
210
- tween: ColorTween(
211
- begin: (i == widget.selectedIndex) ? inactiveColor : activeColor,
212
- end: (i==widget.selectedIndex) ? activeColor : inactiveColor,
213
- ),
214
- duration: iconColorChangeDuration,
215
- builder: (context, value, child) {
216
- return Container(
217
- height: NewMainNavBar.barHeight,
218
- child: CakeImageWidget(imageUrl:
219
- visibleActions[i].image,
220
- width: iconWidth,
221
- height: iconHeight,
222
- //fit: BoxFit.scaleDown,
223
- colorFilter: ColorFilter.mode(
224
- value ?? inactiveColor,
225
- BlendMode.srcIn,
205
+ alignment: Alignment.center,
206
+ child: AnimatedScale(
207
+ duration: inactiveIconAppearDuration,
208
+ curve: Curves.easeOutCubic,
209
+ scale: (i == widget.selectedIndex) ? 0.857 : 1.0,
210
+ child: TweenAnimationBuilder<Color?>(
211
+ tween: ColorTween(
212
+ begin: (i == widget.selectedIndex) ? inactiveColor : activeColor,
213
+ end: (i==widget.selectedIndex) ? activeColor : inactiveColor,
214
+ ),
215
+ duration: iconColorChangeDuration,
216
+ builder: (context, value, child) {
217
+ return Container(
218
+ height: NewMainNavBar.barHeight,
219
+ child: CakeImageWidget(imageUrl:
220
+ visibleActions[i].image,
221
+ width: iconWidth,
222
+ height: iconHeight,
223
+ //fit: BoxFit.scaleDown,
224
+ colorFilter: ColorFilter.mode(
225
+ value ?? inactiveColor,
226
+ BlendMode.srcIn,
227
+ ),
228
),
227
- ),
228
- );
229
- }
229
+ );
230
+ }
231
+ ),
232
),
233
),
234
),
235
),
236
),
235
- ),
236
- ],
237
- ),
238
- )),
237
+ ],
238
+ ),
239
+ )),
240
+ ),
241
),
242
),
243
),
244
),
243
- ),
245
+ );
246
+ },
247
);
248
}
249
}