mirror of
https://github.com/Such-Software/hash-wallet.git
synced 2026-10-17 05:41:29 +02:00
Legal (aligned with the hosted hash.boats Terms & Privacy): - Rewrite in-app Terms (assets/text/Terms_of_Use.txt): drop the false 'No Fiat Operations'/'No Embedded Applications' clauses; add MoonPay (optional, where available), Trocador swaps + ~1% fee disclosure, WalletConnect, sanctions/18+, Apple EULA rider, and PA + class/jury-waiver dispute terms. - Reconcile PRIVACY.md with the canonical hosted policy. - Add THIRD_PARTY_LICENSES.md; correct the blanket 'MIT' claim in README (app code MIT; bundles BSD-3/Apache-2.0/LGPL-3.0); fix README build-doc + LICENSE.md links and copyright year. - Standardize in-app legal naming to 'Terms of Service'; add in-app links to hash.boats/terms and /privacy. Release fixes: - Align URL scheme hashwallet:// -> hashbags:// (matches registered scheme) so WalletConnect/Trezor/quick-action deep links work. - Store-review gating: hide Buy action until a provider is available; hide the empty 'Apps' tab. - Exclude removed chains (SOL/TRX/ZEC/DCR/ZANO + tokens) from swap pickers. - Residual Cake branding: macOS CFBundleDisplayName and Windows Runner.rc (Hash Wallet -> Hash Bags); remove Android cakewallet://y.at leftover. - Genericize internal-infra comments in CI workflows.
398 lines
15 KiB
Dart
398 lines
15 KiB
Dart
import 'dart:math';
|
|
|
|
import 'package:hash_wallet/bitcoin/bitcoin.dart';
|
|
import 'package:hash_wallet/di.dart';
|
|
import 'package:hash_wallet/entities/balance_display_mode.dart';
|
|
import 'package:hash_wallet/entities/bitcoin_amount_display_mode.dart';
|
|
import 'package:hash_wallet/entities/provider_types.dart';
|
|
import 'package:hash_wallet/generated/i18n.dart';
|
|
import 'package:hash_wallet/new-ui/modal_navigator.dart';
|
|
import 'package:hash_wallet/new-ui/pages/send_page.dart';
|
|
import 'package:hash_wallet/routes.dart';
|
|
import 'package:hash_wallet/utils/feature_flag.dart';
|
|
import 'package:hash_wallet/utils/payment_request.dart';
|
|
import 'package:hash_wallet/view_model/dashboard/dashboard_view_model.dart';
|
|
import 'package:hash_wallet/view_model/monero_account_list/monero_account_list_view_model.dart';
|
|
import 'package:cw_core/card_design.dart';
|
|
import 'package:cw_core/crypto_currency.dart';
|
|
import 'package:cw_core/unspent_coin_type.dart';
|
|
import 'package:cw_core/wallet_type.dart';
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:flutter_mobx/flutter_mobx.dart';
|
|
import 'package:mobx/mobx.dart';
|
|
import 'package:modal_bottom_sheet/modal_bottom_sheet.dart';
|
|
|
|
import 'balance_card.dart';
|
|
|
|
class CardsView extends StatefulWidget {
|
|
const CardsView(
|
|
{super.key,
|
|
required this.dashboardViewModel,
|
|
required this.accountListViewModel,
|
|
required this.lightningMode,
|
|
required this.onCompactModeBackgroundCardsTapped, required this.onCustomizeTapped});
|
|
|
|
final DashboardViewModel dashboardViewModel;
|
|
final MoneroAccountListViewModel? accountListViewModel;
|
|
final VoidCallback onCompactModeBackgroundCardsTapped;
|
|
final VoidCallback onCustomizeTapped;
|
|
final bool lightningMode;
|
|
|
|
@override
|
|
_CardsViewState createState() => _CardsViewState();
|
|
}
|
|
|
|
class _CardsViewState extends State<CardsView> {
|
|
late int _selectedIndex;
|
|
bool isFirstBuild = true;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_selectedIndex = widget.dashboardViewModel.cardOrder.length - 1;
|
|
reaction(
|
|
(_) => widget.dashboardViewModel.cardOrder.values.toList(),
|
|
(_) => setState(() {
|
|
_selectedIndex = widget.dashboardViewModel.cardOrder.length - 1;
|
|
}));
|
|
}
|
|
|
|
static const Duration animDuration = Duration(milliseconds: 200);
|
|
static const int compactModeTreshold = 4;
|
|
static const int maxCards = 5;
|
|
|
|
Widget _buildCard(int visualIndex, int realIndex, int numCards, double parentWidth,
|
|
Map<int, int> order, bool compactMode, double overlapAmount) {
|
|
final baseTop = overlapAmount * (numCards - 1);
|
|
final scaleFactor = compactMode ? 1 : 0.96;
|
|
|
|
final howFarBehind = (_selectedIndex - visualIndex + numCards) % numCards;
|
|
final scale = pow(scaleFactor, howFarBehind).toDouble();
|
|
|
|
final top = baseTop - (howFarBehind * overlapAmount);
|
|
|
|
final double cardWidth = MediaQuery.of(context).size.width * 0.878;
|
|
final double effectiveCardWidth = min(cardWidth, 768);
|
|
|
|
final left = (parentWidth - effectiveCardWidth) / 2.0;
|
|
|
|
return AnimatedPositioned(
|
|
key: ValueKey("$visualIndex $realIndex"),
|
|
duration: animDuration,
|
|
curve: Curves.easeOut,
|
|
top: top,
|
|
left: left,
|
|
child: AnimatedScale(
|
|
duration: animDuration,
|
|
curve: Curves.easeOut,
|
|
scale: scale,
|
|
child: GestureDetector(
|
|
onTap: () {
|
|
// printV(visualIndex);
|
|
if (compactMode && visualIndex != 0) {
|
|
widget.onCompactModeBackgroundCardsTapped();
|
|
} else if(!compactMode) {
|
|
setState(() {
|
|
if (widget.accountListViewModel != null)
|
|
widget.accountListViewModel!
|
|
.select(widget.accountListViewModel!.accounts[realIndex]);
|
|
_selectedIndex = visualIndex;
|
|
});
|
|
}
|
|
},
|
|
onLongPress: () {
|
|
if (_selectedIndex == visualIndex) {
|
|
widget.dashboardViewModel.balanceViewModel.switchBalanceValue();
|
|
};
|
|
HapticFeedback.heavyImpact();
|
|
},
|
|
child: Observer(builder: (_) {
|
|
if(realIndex >= (widget.accountListViewModel?.accounts.length ?? 1)) {
|
|
return Container();
|
|
}
|
|
final account = widget.accountListViewModel?.accounts[realIndex];
|
|
|
|
// The second balance should always be the lightning balance
|
|
// printV(widget.dashboardViewModel.balanceViewModel.formattedBalances.first.availableBalance);
|
|
final walletBalanceRecord = widget.dashboardViewModel.balanceViewModel.getMainBalanceRecord(widget.lightningMode);
|
|
|
|
late final String walletBalance;
|
|
late final String walletFiatBalance;
|
|
if (widget.dashboardViewModel.mwebEnabled && widget.dashboardViewModel.hasMweb) {
|
|
if(widget.dashboardViewModel.balanceViewModel.displayMode == BalanceDisplayMode.hiddenBalance) {
|
|
walletBalance = '●●●●●●';
|
|
walletFiatBalance = '●●●●●●';
|
|
} else {
|
|
walletBalance = walletBalanceRecord?.combinedAvailableBalance ?? "0";
|
|
walletFiatBalance = walletBalanceRecord?.combinedFiatAvailableBalance ?? "0.00";
|
|
}
|
|
} else if(widget.dashboardViewModel.balanceViewModel.showCombinedBalance){
|
|
walletBalance = "";
|
|
walletFiatBalance = widget.dashboardViewModel.balanceViewModel.combinedFiatBalance;
|
|
|
|
}else {
|
|
walletBalance = walletBalanceRecord?.availableBalance ?? "0";
|
|
walletFiatBalance = walletBalanceRecord?.fiatAvailableBalanceRaw ?? "0.00";
|
|
}
|
|
|
|
|
|
// the card designs is empty if widget gets built before it loads.
|
|
// should get populated before user sees anything
|
|
final CardDesign cardDesign;
|
|
if (widget.dashboardViewModel.cardDesigns.isEmpty ||
|
|
realIndex >= widget.dashboardViewModel.cardDesigns.length)
|
|
cardDesign = CardDesign.genericDefault;
|
|
else if(widget.lightningMode)
|
|
cardDesign = widget.dashboardViewModel.cardDesigns[realIndex + 1];
|
|
else
|
|
cardDesign = widget.dashboardViewModel.cardDesigns[realIndex];
|
|
|
|
final String accountName;
|
|
final String accountBalance;
|
|
if (account == null) {
|
|
accountName = "";
|
|
accountBalance = "";
|
|
} else {
|
|
accountName = account.label;
|
|
accountBalance = account.balance ?? "0.00";
|
|
}
|
|
|
|
final assetName = widget.dashboardViewModel.balanceViewModel.showCombinedBalance ? "" : walletBalanceRecord?.formattedAssetTitle ?? assetTitleFallback;
|
|
|
|
final List<BalanceCardAction> actions = widget.lightningMode
|
|
? [
|
|
BalanceCardAction(
|
|
label: S.current.bitcoin_lightning_deposit,
|
|
icon: Icons.arrow_downward,
|
|
onTap: depositToL2,
|
|
),
|
|
BalanceCardAction(
|
|
label: S.current.bitcoin_lightning_withdraw,
|
|
icon: Icons.arrow_upward,
|
|
onTap: withdrawFromL2,
|
|
)
|
|
]
|
|
: (widget.dashboardViewModel.isEnabledTradeAction &&
|
|
ProvidersHelper.getAvailableBuyProviderTypes().isNotEmpty)
|
|
? [
|
|
BalanceCardAction(
|
|
label: S.current.buy,
|
|
icon: Icons.arrow_forward_ios_rounded,
|
|
iconSize: 12,
|
|
onTap: () => Navigator.of(context).pushNamed(Routes.buySellPage),
|
|
)
|
|
]
|
|
: [];
|
|
|
|
return BalanceCard(
|
|
width: effectiveCardWidth,
|
|
accountName: accountName,
|
|
accountBalance: accountBalance,
|
|
designSwitchDuration: Duration(milliseconds: 150),
|
|
assetName: assetName,
|
|
capitalizeAssetName: _shouldCapitalizeAssetName(),
|
|
balance: walletBalance,
|
|
fiatCurrencyTitle: walletBalanceRecord?.fiatCurrencyTicker ?? widget.dashboardViewModel.settingsStore.fiatCurrency.title,
|
|
fiatFirst: widget.dashboardViewModel.balanceViewModel.showCombinedBalance,
|
|
fiatBalance: walletFiatBalance,
|
|
selected: _selectedIndex == visualIndex,
|
|
onCustomizeTapped: _selectedIndex == visualIndex ? widget.onCustomizeTapped : null,
|
|
design: cardDesign,
|
|
actions: actions,
|
|
);
|
|
}),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
String get assetTitleFallback => widget.dashboardViewModel.appStore.amountParsingProxy.getCryptoSymbol(
|
|
widget.lightningMode
|
|
? CryptoCurrency.btcln
|
|
: widget.dashboardViewModel.wallet.currency);
|
|
|
|
bool _shouldCapitalizeAssetName() {
|
|
if(widget.dashboardViewModel.wallet.type != WalletType.bitcoin) {
|
|
return true;
|
|
}
|
|
|
|
switch (widget.dashboardViewModel.settingsStore.displayAmountsInSatoshi) {
|
|
case BitcoinAmountDisplayMode.satoshi:
|
|
return false;
|
|
case BitcoinAmountDisplayMode.satoshiForLightning:
|
|
return !widget.lightningMode;
|
|
case BitcoinAmountDisplayMode.bitcoin:
|
|
return true;
|
|
default:
|
|
return true;
|
|
}
|
|
}
|
|
|
|
double _getBoxHeight(int numCards, double overlapAmount) {
|
|
final double cardWidth = MediaQuery.of(context).size.width * 0.878;
|
|
final double effectiveCardWidth = min(cardWidth, 768);
|
|
return
|
|
/* height of initial card */
|
|
(2 / 3.2) * (effectiveCardWidth) +
|
|
/* height of bg card * amount of bg cards */
|
|
overlapAmount * ((numCards) - 1);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Observer(builder: (context) {
|
|
final parentWidth = MediaQuery.of(context).size.width;
|
|
final children = <Widget>[];
|
|
|
|
int numCards = widget.dashboardViewModel.wallet.type == WalletType.bitcoin
|
|
? 1
|
|
: widget.dashboardViewModel.cardDesigns.length;
|
|
if(numCards == 0) numCards = 1;
|
|
|
|
if (_selectedIndex >= (numCards)) {
|
|
_selectedIndex = 0;
|
|
}
|
|
|
|
Map<int, int> order = widget.dashboardViewModel.cardOrder.length != numCards
|
|
? Map<int, int>.fromEntries(
|
|
List.generate(numCards, (i) => MapEntry(i, i)),
|
|
)
|
|
: widget.dashboardViewModel.cardOrder;
|
|
|
|
for (int i = min(numCards - 1, maxCards); i >= 0; i--) {
|
|
int visualIndex = (_selectedIndex - i + numCards) % numCards;
|
|
if (order[visualIndex] == null) {
|
|
order = Map<int, int>.fromEntries(
|
|
List.generate(numCards, (i) => MapEntry(i, i)),
|
|
);
|
|
}
|
|
}
|
|
|
|
|
|
final bool compactMode = numCards >= compactModeTreshold;
|
|
final double overlapAmount = compactMode ? 5.0 : 46.0;
|
|
for (int i = min(numCards - 1, maxCards); i >= 0; i--) {
|
|
int visualIndex = (_selectedIndex - i + numCards) % numCards;
|
|
|
|
int realIndex = order[visualIndex]!;
|
|
|
|
if (visualIndex == _selectedIndex &&
|
|
widget.accountListViewModel != null &&
|
|
realIndex < widget.accountListViewModel!.accounts.length &&
|
|
widget.accountListViewModel?.selected.label !=
|
|
widget.accountListViewModel?.accounts[realIndex].label) {
|
|
widget.accountListViewModel!
|
|
.select(widget.accountListViewModel!.accounts[realIndex]);
|
|
}
|
|
|
|
children.add(_buildCard(
|
|
visualIndex, realIndex, numCards, parentWidth, order, compactMode, overlapAmount));
|
|
}
|
|
|
|
return AnimatedContainer(
|
|
duration: Duration(milliseconds: 200),
|
|
curve: Curves.easeOut,
|
|
width: double.infinity,
|
|
height: _getBoxHeight(numCards, overlapAmount),
|
|
child: SizedBox(
|
|
key: ValueKey(_getBoxHeight(numCards, overlapAmount)),
|
|
width: double.infinity,
|
|
height: _getBoxHeight(numCards, overlapAmount),
|
|
child: Stack(alignment: Alignment.center, children: children),
|
|
),
|
|
);
|
|
});
|
|
}
|
|
|
|
Future<void> depositToL2() async {
|
|
PaymentRequest? paymentRequest = null;
|
|
|
|
if (widget.dashboardViewModel.type == WalletType.litecoin) {
|
|
final depositAddress = bitcoin!.getUnusedMwebAddress(widget.dashboardViewModel.wallet);
|
|
if ((depositAddress?.isNotEmpty ?? false)) {
|
|
paymentRequest = PaymentRequest.fromUri(Uri.parse("litecoin:$depositAddress"));
|
|
}
|
|
} else if (widget.dashboardViewModel.type == WalletType.bitcoin) {
|
|
final depositAddress =
|
|
await bitcoin!.getUnusedSpakDepositAddress(widget.dashboardViewModel.wallet);
|
|
if ((depositAddress?.isNotEmpty ?? false)) {
|
|
paymentRequest = PaymentRequest.fromUri(Uri.parse("bitcoin:$depositAddress"));
|
|
}
|
|
}
|
|
|
|
if (FeatureFlag.hasNewUiExtraPages && widget.dashboardViewModel.type == WalletType.bitcoin) {
|
|
final page = getIt.get<NewSendPage>(
|
|
param1: SendPageParams(
|
|
initialPaymentRequest: paymentRequest,
|
|
unspentCoinType: UnspentCoinType.nonMweb,
|
|
mode: SendPageModes.lightningDeposit,
|
|
));
|
|
showCupertinoModalBottomSheet(context: context, barrierColor: Colors.black.withAlpha(128), builder: (context){
|
|
return Padding(
|
|
padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
|
|
child: SizedBox(
|
|
height:MediaQuery.of(context).size.height*0.6,
|
|
child:ModalNavigator(parentContext:context,rootPage: Material(child: page))
|
|
),
|
|
);
|
|
});
|
|
} else {
|
|
Navigator.pushNamed(
|
|
context,
|
|
Routes.send,
|
|
arguments: {
|
|
'paymentRequest': paymentRequest,
|
|
'coinTypeToSpendFrom': UnspentCoinType.nonMweb,
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
Future<void> withdrawFromL2() async {
|
|
PaymentRequest? paymentRequest = null;
|
|
UnspentCoinType unspentCoinType = UnspentCoinType.any;
|
|
final withdrawAddress = bitcoin!.getUnusedSegwitAddress(widget.dashboardViewModel.wallet);
|
|
|
|
if (widget.dashboardViewModel.type == WalletType.litecoin) {
|
|
if ((withdrawAddress?.isNotEmpty ?? false)) {
|
|
paymentRequest = PaymentRequest.fromUri(Uri.parse("litecoin:$withdrawAddress"));
|
|
}
|
|
unspentCoinType = UnspentCoinType.mweb;
|
|
} else if (widget.dashboardViewModel.type == WalletType.bitcoin) {
|
|
if ((withdrawAddress?.isNotEmpty ?? false)) {
|
|
paymentRequest = PaymentRequest.fromUri(Uri.parse("bitcoin:$withdrawAddress"));
|
|
}
|
|
unspentCoinType = UnspentCoinType.lightning;
|
|
}
|
|
|
|
if (FeatureFlag.hasNewUiExtraPages && widget.dashboardViewModel.type == WalletType.bitcoin) {
|
|
final page = getIt.get<NewSendPage>(
|
|
param1: SendPageParams(
|
|
initialPaymentRequest: paymentRequest,
|
|
unspentCoinType: unspentCoinType,
|
|
mode: SendPageModes.lightningWithdrawal,
|
|
));
|
|
showCupertinoModalBottomSheet(context: context, barrierColor: Colors.black.withAlpha(128), builder: (context){
|
|
return Padding(
|
|
padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
|
|
child: SizedBox(
|
|
height:MediaQuery.of(context).size.height*0.6,
|
|
child:ModalNavigator(parentContext:context,rootPage: Material(child: page))
|
|
),
|
|
);
|
|
});
|
|
} else {
|
|
Navigator.pushNamed(
|
|
context,
|
|
Routes.send,
|
|
arguments: {
|
|
'paymentRequest': paymentRequest,
|
|
'coinTypeToSpendFrom': unspentCoinType,
|
|
},
|
|
);
|
|
}
|
|
}
|
|
}
|