dev
dart 424 lines 16.5 KB
Raw
1 import 'dart:math';
2
3 import 'package:cake_wallet/bitcoin/bitcoin.dart';
4 import 'package:cake_wallet/di.dart';
5 import 'package:cake_wallet/entities/balance_display_mode.dart';
6 import 'package:cake_wallet/entities/bitcoin_amount_display_mode.dart';
7 import 'package:cake_wallet/generated/i18n.dart';
8 import 'package:cake_wallet/new-ui/modal_navigator.dart';
9 import 'package:cake_wallet/new-ui/pages/send_page.dart';
10 import 'package:cake_wallet/routes.dart';
11 import 'package:cake_wallet/utils/feature_flag.dart';
12 import 'package:cake_wallet/utils/payment_request.dart';
13 import 'package:cake_wallet/utils/responsive_layout_util.dart';
14 import 'package:cake_wallet/view_model/dashboard/dashboard_view_model.dart';
15 import 'package:cake_wallet/view_model/monero_account_list/monero_account_list_view_model.dart';
16 import 'package:cw_core/card_design.dart';
17 import 'package:cw_core/crypto_currency.dart';
18 import 'package:cw_core/unspent_coin_type.dart';
19 import 'package:cw_core/wallet_type.dart';
20 import 'package:flutter/foundation.dart';
21 import 'package:flutter/material.dart';
22 import 'package:flutter/services.dart';
23 import 'package:flutter_mobx/flutter_mobx.dart';
24 import 'package:mobx/mobx.dart';
25 import 'package:modal_bottom_sheet/modal_bottom_sheet.dart';
26
27 import 'balance_card.dart';
28
29 class CardsView extends StatefulWidget {
30 const CardsView(
31 {super.key,
32 required this.dashboardViewModel,
33 required this.accountListViewModel,
34 required this.lightningMode,
35 required this.onCompactModeBackgroundCardsTapped,
36 required this.onCustomizeTapped});
37
38 final DashboardViewModel dashboardViewModel;
39 final MoneroAccountListViewModel? accountListViewModel;
40 final VoidCallback onCompactModeBackgroundCardsTapped;
41 final VoidCallback onCustomizeTapped;
42 final bool lightningMode;
43
44 @override
45 _CardsViewState createState() => _CardsViewState();
46 }
47
48 class _CardsViewState extends State<CardsView> {
49 late int _selectedIndex;
50 bool isFirstBuild = true;
51
52 @override
53 void initState() {
54 super.initState();
55 _selectedIndex = widget.dashboardViewModel.cardOrder.length - 1;
56 reaction(
57 (_) => widget.dashboardViewModel.cardOrder.values.toList(),
58 (_) => setState(() {
59 _selectedIndex = widget.dashboardViewModel.cardOrder.length - 1;
60 }));
61 }
62
63 static const Duration animDuration = Duration(milliseconds: 200);
64 static const int compactModeTreshold = 4;
65 static const int maxCards = 5;
66
67 Widget _buildCard(int visualIndex, int realIndex, int numCards, double parentWidth,
68 Map<int, int> order, bool compactMode, double overlapAmount) {
69 final baseTop = overlapAmount * (numCards - 1);
70 final scaleFactor = compactMode ? 1 : 0.96;
71
72 final howFarBehind = (_selectedIndex - visualIndex + numCards) % numCards;
73 final scale = pow(scaleFactor, howFarBehind).toDouble();
74
75 final top = baseTop - (howFarBehind * overlapAmount);
76
77 final left = (parentWidth - effectiveCardWidth) / 2.0;
78
79 final isSelected = _selectedIndex == visualIndex;
80 final accounts = widget.accountListViewModel?.accounts;
81 final cardLabel = (accounts != null && realIndex < accounts.length)
82 ? accounts[realIndex].label
83 : S.of(context).balance;
84
85 void onCardTap() {
86 // printV(visualIndex);
87 if (compactMode && visualIndex != 0) {
88 widget.onCompactModeBackgroundCardsTapped();
89 } else if (!compactMode) {
90 setState(() {
91 if (widget.accountListViewModel != null)
92 widget.accountListViewModel!.select(widget.accountListViewModel!.accounts[realIndex]);
93 _selectedIndex = visualIndex;
94 });
95 }
96 }
97
98 void onCardLongPress() {
99 if (_selectedIndex == visualIndex) {
100 widget.dashboardViewModel.balanceViewModel.switchBalanceValue();
101 }
102 HapticFeedback.heavyImpact();
103 }
104
105 return AnimatedPositioned(
106 key: ValueKey("$visualIndex $realIndex"),
107 duration: animDuration,
108 curve: Curves.easeOut,
109 top: top,
110 left: left,
111 child: AnimatedScale(
112 duration: animDuration,
113 curve: Curves.easeOut,
114 scale: scale,
115 // The card is the tap target; the balances and the card's own buttons stay
116 // reachable as children of this node.
117 child: Semantics(
118 button: true,
119 selected: isSelected,
120 label: cardLabel,
121 hint: isSelected
122 ? (widget.dashboardViewModel.balanceViewModel.displayMode ==
123 BalanceDisplayMode.hiddenBalance
124 ? S.of(context).long_press_show_balance
125 : S.of(context).long_press_hide_balance)
126 : null,
127 onTap: onCardTap,
128 onLongPress: isSelected ? onCardLongPress : null,
129 child: GestureDetector(
130 excludeFromSemantics: true,
131 onTap: onCardTap,
132 onLongPress: onCardLongPress,
133 child: Observer(builder: (_) {
134 if (realIndex >= (widget.accountListViewModel?.accounts.length ?? 1)) {
135 return Container();
136 }
137 final account = widget.accountListViewModel?.accounts[realIndex];
138
139 // The second balance should always be the lightning balance
140 // printV(widget.dashboardViewModel.balanceViewModel.formattedBalances.first.availableBalance);
141 final walletBalanceRecord = widget.dashboardViewModel.balanceViewModel
142 .getMainBalanceRecord(widget.lightningMode);
143
144 late final String walletBalance;
145 late final String walletFiatBalance;
146 if (widget.dashboardViewModel.mwebEnabled && widget.dashboardViewModel.hasMweb) {
147 if (widget.dashboardViewModel.balanceViewModel.displayMode ==
148 BalanceDisplayMode.hiddenBalance) {
149 walletBalance = '●●●●●●';
150 walletFiatBalance = '●●●●●●';
151 } else {
152 walletBalance = walletBalanceRecord?.combinedAvailableBalance ?? "0";
153 walletFiatBalance = walletBalanceRecord?.combinedFiatAvailableBalance ?? "0.00";
154 }
155 } else if (widget.dashboardViewModel.balanceViewModel.showCombinedBalance) {
156 walletBalance = "";
157 walletFiatBalance = widget.dashboardViewModel.balanceViewModel.combinedFiatBalance;
158 } else {
159 walletBalance = walletBalanceRecord?.availableBalance ?? "0";
160 walletFiatBalance = walletBalanceRecord?.fiatAvailableBalance ?? "0.00";
161 }
162
163 // the card designs is empty if widget gets built before it loads.
164 // should get populated before user sees anything
165 final CardDesign cardDesign;
166 if (widget.dashboardViewModel.cardDesigns.isEmpty ||
167 realIndex >= widget.dashboardViewModel.cardDesigns.length)
168 cardDesign = CardDesign.genericDefault;
169 else if (widget.lightningMode)
170 cardDesign = widget.dashboardViewModel.cardDesigns[realIndex + 1];
171 else
172 cardDesign = widget.dashboardViewModel.cardDesigns[realIndex];
173
174 final String accountName;
175 final String accountBalance;
176 if (account == null) {
177 accountName = "";
178 accountBalance = "";
179 } else {
180 accountName = account.label;
181 accountBalance = account.balance ?? "0.00";
182 }
183
184 final assetName = widget.dashboardViewModel.balanceViewModel.showCombinedBalance
185 ? ""
186 : walletBalanceRecord?.formattedAssetTitle ?? assetTitleFallback;
187
188 final List<BalanceCardAction> actions = widget.lightningMode
189 ? [
190 BalanceCardAction(
191 label: S.current.bitcoin_lightning_deposit,
192 icon: Icons.arrow_downward,
193 onTap: depositToL2,
194 ),
195 BalanceCardAction(
196 label: S.current.bitcoin_lightning_withdraw,
197 icon: Icons.arrow_upward,
198 onTap: withdrawFromL2,
199 )
200 ]
201 : widget.dashboardViewModel.isEnabledTradeAction
202 ? [
203 BalanceCardAction(
204 label: S.current.buy,
205 icon: Icons.arrow_forward_ios_rounded,
206 iconSize: 12,
207 onTap: () => Navigator.of(context).pushNamed(Routes.buySellPage),
208 )
209 ]
210 : [];
211
212 return BalanceCard(
213 width: effectiveCardWidth,
214 accountName: accountName,
215 accountBalance: accountBalance,
216 designSwitchDuration: Duration(milliseconds: 150),
217 assetName: assetName,
218 capitalizeAssetName: _shouldCapitalizeAssetName(),
219 balance: walletBalance,
220 fiatCurrencyTitle: walletBalanceRecord?.fiatCurrency?.title ??
221 widget.dashboardViewModel.settingsStore.fiatCurrency.title,
222 fiatFirst: widget.dashboardViewModel.balanceViewModel.showCombinedBalance,
223 fiatBalance: walletFiatBalance,
224 selected: _selectedIndex == visualIndex,
225 onCustomizeTapped: _selectedIndex == visualIndex ? widget.onCustomizeTapped : null,
226 design: cardDesign,
227 actions: actions,
228 );
229 }),
230 ),
231 ),
232 ),
233 );
234 }
235
236 String get assetTitleFallback =>
237 widget.dashboardViewModel.appStore.amountParsingProxy.getCryptoSymbol(
238 widget.lightningMode ? CryptoCurrency.btcln : widget.dashboardViewModel.wallet.currency);
239
240 bool _shouldCapitalizeAssetName() {
241 if (widget.dashboardViewModel.wallet.type != WalletType.bitcoin) {
242 return true;
243 }
244
245 switch (widget.dashboardViewModel.settingsStore.displayAmountsInSatoshi) {
246 case BitcoinAmountDisplayMode.satoshi:
247 return false;
248 case BitcoinAmountDisplayMode.satoshiForLightning:
249 return !widget.lightningMode;
250 case BitcoinAmountDisplayMode.bitcoin:
251 return true;
252 default:
253 return true;
254 }
255 }
256
257 double get effectiveCardWidth => min(MediaQuery.of(context).size.width * 0.878,
258 responsiveLayoutUtil.shouldRenderMobileUI ? 768 : 512);
259
260 double _getBoxHeight(int numCards, double overlapAmount) {
261 return
262 /* height of initial card */
263 (2 / 3.2) * (effectiveCardWidth) +
264 /* height of bg card * amount of bg cards */
265 overlapAmount * ((numCards) - 1);
266 }
267
268 @override
269 Widget build(BuildContext context) {
270 return Observer(builder: (context) {
271 final parentWidth = MediaQuery.of(context).size.width;
272 final children = <Widget>[];
273
274 int numCards = widget.dashboardViewModel.wallet.type == WalletType.bitcoin
275 ? 1
276 : widget.dashboardViewModel.cardDesigns.length;
277 if (numCards == 0) numCards = 1;
278
279 if (_selectedIndex >= (numCards)) {
280 _selectedIndex = 0;
281 }
282
283 Map<int, int> order = widget.dashboardViewModel.cardOrder.length != numCards
284 ? Map<int, int>.fromEntries(
285 List.generate(numCards, (i) => MapEntry(i, i)),
286 )
287 : widget.dashboardViewModel.cardOrder;
288
289 for (int i = min(numCards - 1, maxCards); i >= 0; i--) {
290 int visualIndex = (_selectedIndex - i + numCards) % numCards;
291 if (order[visualIndex] == null) {
292 order = Map<int, int>.fromEntries(
293 List.generate(numCards, (i) => MapEntry(i, i)),
294 );
295 }
296 }
297
298 final bool compactMode = numCards >= compactModeTreshold;
299 final double overlapAmount = compactMode ? 5.0 : 46.0;
300 for (int i = min(numCards - 1, maxCards); i >= 0; i--) {
301 int visualIndex = (_selectedIndex - i + numCards) % numCards;
302
303 int realIndex = order[visualIndex]!;
304
305 if (visualIndex == _selectedIndex &&
306 widget.accountListViewModel != null &&
307 realIndex < widget.accountListViewModel!.accounts.length &&
308 widget.accountListViewModel?.selected.label !=
309 widget.accountListViewModel?.accounts[realIndex].label) {
310 widget.accountListViewModel!.select(widget.accountListViewModel!.accounts[realIndex]);
311 }
312
313 children.add(_buildCard(
314 visualIndex, realIndex, numCards, parentWidth, order, compactMode, overlapAmount));
315 }
316
317 return AnimatedContainer(
318 duration: Duration(milliseconds: 200),
319 curve: Curves.easeOut,
320 width: double.infinity,
321 height: _getBoxHeight(numCards, overlapAmount),
322 child: SizedBox(
323 key: ValueKey(_getBoxHeight(numCards, overlapAmount)),
324 width: double.infinity,
325 height: _getBoxHeight(numCards, overlapAmount),
326 child: Stack(alignment: Alignment.center, children: children),
327 ),
328 );
329 });
330 }
331
332 Future<void> depositToL2() async {
333 PaymentRequest? paymentRequest = null;
334
335 if (widget.dashboardViewModel.type == WalletType.litecoin) {
336 final depositAddress = bitcoin!.getUnusedMwebAddress(widget.dashboardViewModel.wallet);
337 if ((depositAddress?.isNotEmpty ?? false)) {
338 paymentRequest = PaymentRequest.fromUri(Uri.parse("litecoin:$depositAddress"));
339 }
340 } else if (widget.dashboardViewModel.type == WalletType.bitcoin) {
341 final depositAddress =
342 await bitcoin!.getUnusedSpakDepositAddress(widget.dashboardViewModel.wallet);
343 if ((depositAddress?.isNotEmpty ?? false)) {
344 paymentRequest = PaymentRequest.fromUri(Uri.parse("bitcoin:$depositAddress"));
345 }
346 }
347
348 if (FeatureFlag.hasNewUiExtraPages && widget.dashboardViewModel.type == WalletType.bitcoin) {
349 final page = getIt.get<NewSendPage>(
350 param1: SendPageParams(
351 initialPaymentRequest: paymentRequest,
352 unspentCoinType: UnspentCoinType.nonMweb,
353 mode: SendPageModes.lightningDeposit,
354 ));
355 showCupertinoModalBottomSheet(
356 context: context,
357 barrierColor: Colors.black.withAlpha(128),
358 builder: (context) {
359 return Padding(
360 padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
361 child: SizedBox(
362 height: MediaQuery.of(context).size.height * 0.6,
363 child: ModalNavigator(parentContext: context, rootPage: Material(child: page))),
364 );
365 });
366 } else {
367 Navigator.pushNamed(
368 context,
369 Routes.send,
370 arguments: {
371 'paymentRequest': paymentRequest,
372 'coinTypeToSpendFrom': UnspentCoinType.nonMweb,
373 },
374 );
375 }
376 }
377
378 Future<void> withdrawFromL2() async {
379 PaymentRequest? paymentRequest = null;
380 UnspentCoinType unspentCoinType = UnspentCoinType.any;
381 final withdrawAddress = bitcoin!.getUnusedSegwitAddress(widget.dashboardViewModel.wallet);
382
383 if (widget.dashboardViewModel.type == WalletType.litecoin) {
384 if ((withdrawAddress?.isNotEmpty ?? false)) {
385 paymentRequest = PaymentRequest.fromUri(Uri.parse("litecoin:$withdrawAddress"));
386 }
387 unspentCoinType = UnspentCoinType.mweb;
388 } else if (widget.dashboardViewModel.type == WalletType.bitcoin) {
389 if ((withdrawAddress?.isNotEmpty ?? false)) {
390 paymentRequest = PaymentRequest.fromUri(Uri.parse("bitcoin:$withdrawAddress"));
391 }
392 unspentCoinType = UnspentCoinType.lightning;
393 }
394
395 if (FeatureFlag.hasNewUiExtraPages && widget.dashboardViewModel.type == WalletType.bitcoin) {
396 final page = getIt.get<NewSendPage>(
397 param1: SendPageParams(
398 initialPaymentRequest: paymentRequest,
399 unspentCoinType: unspentCoinType,
400 mode: SendPageModes.lightningWithdrawal,
401 ));
402 showCupertinoModalBottomSheet(
403 context: context,
404 barrierColor: Colors.black.withAlpha(128),
405 builder: (context) {
406 return Padding(
407 padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
408 child: SizedBox(
409 height: MediaQuery.of(context).size.height * 0.6,
410 child: ModalNavigator(parentContext: context, rootPage: Material(child: page))),
411 );
412 });
413 } else {
414 Navigator.pushNamed(
415 context,
416 Routes.send,
417 arguments: {
418 'paymentRequest': paymentRequest,
419 'coinTypeToSpendFrom': unspentCoinType,
420 },
421 );
422 }
423 }
424 }