| 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 | } |