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 }