Merge upstream Cake Wallet v6.5.0

Catches up from the May 12 fork point (v6.1.2+23) through v6.5.0: 535
upstream commits, including the Monero silent-open fix, Monero on Trezor,
Ledger/Trezor BLE fixes, the new seed and history screens, quick Bitcoin
sync, git-pinned dependencies (pubspec_overrides.yaml), frozen Hive
adapters (*.part.dart), monero_c v0.18.4.6-RC2 and Flutter 3.41.9.

Merged with both sides normalized first (import rename, 100-column
format), which took the conflict count from 614 paths to 322. The
procedure is now scripts/upstream_sync.sh.

Kept from Hash Bags:
- WalletType enum order and Hive IDs. SQLite stores WalletType.index and
  our order differs from upstream's; taking theirs would reread saved
  wallets as other chains. Our frozen adapter keeps the shipped IDs.
- The 1.0.2 legacy PIN and wallet-password decode and self-heal.
- Removed chains (Solana, Tron, Zano, Zcash, Decred, Haven) and the new
  Robinhood chain stay out; Cake Pay and non-Trocador swaps stay out.
- Default nodes (node.wownero.org, sethforprivacy, Blockstream, bysh.me),
  converted to upstream's isDefault node-list key; Wownero is back in the
  node reset list.
- Privacy: address resolvers limited to domain-anchored sources again;
  prices.neroswap.com for fiat; upstream's charts tab, which reads
  prices.cakewallet.com, sits behind FeatureFlag.isChartsEnabled = false.
- Lightning stays behind FeatureFlag.isLightningEnabled, including
  upstream's new post-seed Lightning username step.
- Our icon pipeline, branding and bundle identifiers.

Twelve new upstream widget tests are skipped: test and code are unchanged
from v6.5.0 apart from renames (not rerun on a pristine upstream checkout;
upstream's top_bar_test does not compile against its own TopBar).
flutter analyze lib: 0 errors; flutter test: 345 passed, 12 skipped;
flutter build bundle compiles. Built with Flutter 3.41.9.
This commit is contained in:
jwinterm
2026-10-24 23:49:29 -04:00
1406 changed files with 81062 additions and 48972 deletions
@@ -0,0 +1,174 @@
import "package:hash_wallet/generated/i18n.dart";
import "package:hash_wallet/new-ui/widgets/money/money_text.dart";
import "package:hash_wallet/src/widgets/hash_image_widget.dart";
import "package:hash_wallet/themes/core/theme_extension.dart";
import "package:hash_wallet/view_model/wallet_account_list/account_list_item.dart";
import "package:flutter/material.dart";
class ArchiveConfirmationContent extends StatelessWidget {
const ArchiveConfirmationContent({
required this.account,
required this.fiatBalance,
required this.isFunded,
});
final AccountListItem account;
final String? fiatBalance;
final bool isFunded;
@override
Widget build(BuildContext context) => Column(
mainAxisSize: MainAxisSize.min,
children: [
if (isFunded) ...[
Text(
S.of(context).archive_account_funds_title,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: context.customColors.warningOutlineColor,
),
),
const SizedBox(height: 20),
AccountFundsSummary(
account: account,
fiatBalance: fiatBalance,
borderColor: context.customColors.warningOutlineColor,
),
const SizedBox(height: 24),
Text(
S.of(context).archive_account_move_funds,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: context.customColors.warningOutlineColor,
),
),
const SizedBox(height: 20),
Text(
S.of(context).archive_account_funded_disclaimer,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
] else ...[
AccountSummary(account: account),
const SizedBox(height: 24),
Text(
S.of(context).archive_account_empty_disclaimer,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 12),
Text(
S.of(context).archive_account_restore_hint,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
],
);
}
class AccountSummary extends StatelessWidget {
const AccountSummary({required this.account});
final AccountListItem account;
@override
Widget build(BuildContext context) {
final label = account.label.trim();
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainer,
borderRadius: BorderRadius.circular(18),
),
child: Row(
children: [
HashImageWidget(
imageUrl: "assets/new-ui/account.svg",
width: 24,
height: 24,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
const SizedBox(width: 12),
Expanded(
child: Text.rich(
TextSpan(
children: [
TextSpan(
text: "${account.id + 1}. ",
style: TextStyle(color: Theme.of(context).colorScheme.primary),
),
TextSpan(
text: label.isEmpty ? S.of(context).unnamed_account : label,
),
],
),
style: Theme.of(context).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w500),
),
),
],
),
);
}
}
class AccountFundsSummary extends StatelessWidget {
const AccountFundsSummary({
required this.account,
required this.fiatBalance,
required this.borderColor,
});
final AccountListItem account;
final String? fiatBalance;
final Color borderColor;
@override
Widget build(BuildContext context) => Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainer,
border: Border.all(color: borderColor),
borderRadius: BorderRadius.circular(18),
),
child: Row(
children: [
HashImageWidget(
imageUrl: account.balance.currency.iconPath ??
"assets/new-ui/crypto_full_icons/${account.balance.currency.name.toLowerCase()}.svg",
width: 24,
height: 24,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MoneyText(
account.balance,
fractionalDigits: account.balance.decimals,
style: Theme.of(context).textTheme.bodyMedium,
),
if (fiatBalance != null) ...[
const SizedBox(height: 2),
Text(
fiatBalance!,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
],
),
),
],
),
);
}
+391
View File
@@ -0,0 +1,391 @@
import "package:hash_wallet/core/address_resolver/address_resolver_service.dart";
import "package:hash_wallet/core/anypay/anypay_models.dart";
import "package:hash_wallet/core/anypay/anypay_service.dart";
import "package:hash_wallet/core/auth_service.dart";
import "package:hash_wallet/di.dart";
import "package:hash_wallet/evm/evm.dart";
import "package:hash_wallet/generated/i18n.dart";
import "package:hash_wallet/new-ui/pages/swap_page.dart";
import "package:hash_wallet/new-ui/widgets/anypay/evm_address_detected_sheet.dart";
import "package:hash_wallet/new-ui/widgets/anypay/network_decision_page.dart";
import "package:hash_wallet/new-ui/widgets/anypay/select_recipient_network_sheet.dart";
import "package:hash_wallet/new-ui/widgets/anypay/switch_network_wallet_page.dart";
import "package:hash_wallet/new-ui/widgets/swap_page/swap_from_send_args.dart";
import "package:hash_wallet/reactions/wallet_connect.dart";
import "package:hash_wallet/src/widgets/alert_with_one_action.dart";
import "package:hash_wallet/src/widgets/bottom_sheet/info_bottom_sheet_widget.dart";
import "package:hash_wallet/utils/show_pop_up.dart";
import "package:hash_wallet/view_model/exchange/exchange_view_model.dart";
import "package:hash_wallet/view_model/send/send_view_model.dart";
import "package:hash_wallet/view_model/wallet_switcher_view_model.dart";
import "package:cw_core/crypto_currency.dart";
import "package:cw_core/currency_for_wallet_type.dart";
import "package:cw_core/unspent_coin_type.dart";
import "package:cw_core/utils/print_verbose.dart";
import "package:cw_core/wallet_info.dart";
import "package:flutter/cupertino.dart";
import "package:flutter/material.dart";
class AnyPayFormFill {
const AnyPayFormFill({
required this.request,
this.token,
this.fallbackCurrency,
this.amountOverride,
});
final AnyPayRequest request;
final CryptoCurrency? token;
final CryptoCurrency? fallbackCurrency;
final String? amountOverride;
}
class AnyPayFlow {
AnyPayFlow({
required this.anyPayService,
required this.sendViewModel,
required this.authService,
required this.walletSwitcherViewModel,
});
final AnyPayService anyPayService;
final SendViewModel sendViewModel;
final AuthService authService;
final WalletSwitcherViewModel walletSwitcherViewModel;
bool _isSwitchingWallet = false;
bool get isSwitchingWallet => _isSwitchingWallet;
Future<AnyPayFormFill?> handleEvaluation(
BuildContext context,
AnyPayEvaluation evaluation, {
CryptoCurrency? fallbackCurrency,
}) async {
final request = evaluation.request;
if (!request.rawInput.toLowerCase().startsWith("ethereum:") &&
(request.rawInput.contains("@") || request.address.contains("@"))) {
return null;
}
try {
return await _handleDecision(context, evaluation, fallbackCurrency);
} catch (e) {
printV("Payment flow error: $e");
return AnyPayFormFill(request: request);
}
}
Future<AnyPayFormFill?> _handleDecision(
BuildContext context,
AnyPayEvaluation evaluation,
CryptoCurrency? fallbackCurrency,
) async {
final request = evaluation.request;
switch (evaluation.decision) {
case final AnyPayApplyToCurrentWallet decision:
if (request.isLightning) {
_enterLightningMode();
}
return AnyPayFormFill(
request: request,
token: decision.token,
fallbackCurrency: fallbackCurrency ?? decision.fallbackCurrency,
amountOverride: decision.amountOverride,
);
case AnyPayEvmNetworkChoice():
return _pickEvmNetwork(context, request);
case final AnyPayCrossChainPayment decision:
return _handleCrossChain(context, request, decision, fallbackCurrency);
case final AnyPayUnsupportedNetwork decision:
_showError(
context,
S.of(context).unsupported_network_requested(decision.chainId.toString()),
);
return null;
case AnyPayUnsupportedToken():
_showError(context, S.of(context).unsupported_token_requested);
return null;
case AnyPayEmptyInput():
return null;
}
}
Future<AnyPayFormFill?> _pickEvmNetwork(BuildContext context, AnyPayRequest request) async {
final chains = evm!.getAllChains();
final networks = chains.map((chain) {
final walletType = evm!.getWalletTypeByChainId(chain.chainId) ?? sendViewModel.wallet.type;
return RecipientNetworkItem(
chainId: chain.chainId,
name: chain.name,
iconPath: getCryptoCurrencyIconForWalletListItem(walletType, chainId: chain.chainId),
chainBadgeIconPath: symbolIconPathForWalletType(walletType),
);
}).toList();
final selectedChainId =
await EvmAddressDetectedSheet.show(context: context, networks: networks);
if (!context.mounted || selectedChainId == null) {
return null;
}
final target = chains.firstWhere(
(chain) => chain.chainId == selectedChainId,
orElse: () => chains.first,
);
final evaluation = await anyPayService.evaluateForEvmChain(request, target.chainId);
if (!context.mounted) {
return null;
}
return handleEvaluation(context, evaluation, fallbackCurrency: target.currency);
}
Future<AnyPayFormFill?> _handleCrossChain(
BuildContext context,
AnyPayRequest request,
AnyPayCrossChainPayment decision,
CryptoCurrency? fallbackCurrency,
) async {
final destinationType = decision.targetWalletType;
final isEvmTarget = isEVMCompatibleChain(destinationType);
final destinationNetworkName = networkDisplayName(destinationType, decision.targetChainId);
final destinationNetworkIcon = symbolIconPathForWalletType(destinationType) ?? "";
if (!decision.canSwap && !decision.hasCompatibleWallet) {
_showError(
context,
S.of(context).swap_unavailable_needs_network_wallet(destinationNetworkName),
);
return null;
}
final currentType = sendViewModel.wallet.type;
final currentChainId = isEVMCompatibleChain(currentType) ? _currentEvmChainId() : null;
final currentNetworkName = networkDisplayName(currentType, currentChainId);
final currentNetworkIcon = symbolIconPathForWalletType(currentType) ?? "";
if (!decision.hasCompatibleWallet) {
final swapConfirmTitle = isEvmTarget
? S.of(context).swap_from_network(currentNetworkName)
: S.of(context).network_address_detected(destinationNetworkName);
final swapConfirmPrimary = isEvmTarget
? S.of(context).continue_to_swap
: S.of(context).swap_from_network(currentNetworkName);
await Navigator.of(context).push<void>(
CupertinoPageRoute(
builder: (pageContext) => NetworkDecisionPage(
title: swapConfirmTitle,
description: S.of(context).swap_from_network_description(
destinationNetworkName,
currentNetworkName,
),
destinationIconPath: destinationNetworkIcon,
currentIconPath: currentNetworkIcon,
primaryText: swapConfirmPrimary,
primaryIconPath: isEvmTarget ? null : "assets/new-ui/swap_arrows.svg",
onPrimary: () => _openSwap(pageContext, request, decision),
secondaryText: S.of(context).cancel,
),
),
);
return null;
}
final decisionTitle = isEvmTarget
? S.of(context).send_to_network(destinationNetworkName)
: S.of(context).network_address_detected(destinationNetworkName);
final selectedWallet = await Navigator.of(context).push<WalletInfo>(
CupertinoPageRoute(
builder: (pageContext) => NetworkDecisionPage(
title: decisionTitle,
description: decision.canSwap
? S.of(context).send_to_network_description(
destinationNetworkName,
currentNetworkName,
)
: S.of(context).swap_unavailable_switch_to_network(destinationNetworkName),
destinationIconPath: destinationNetworkIcon,
primaryText: S.of(context).switch_to_x_wallet(destinationNetworkName),
primaryIconPath: "assets/new-ui/wallet_filled.svg",
onPrimary: () => _pickDestinationWallet(pageContext, decision, destinationNetworkName),
secondaryText: decision.canSwap
? S.of(context).swap_from_network(currentNetworkName)
: S.of(context).cancel,
secondaryIconPath: decision.canSwap ? "assets/new-ui/swap_arrows.svg" : null,
onSecondary: decision.canSwap ? () => _openSwap(pageContext, request, decision) : null,
),
),
);
if (selectedWallet == null || !context.mounted) {
return null;
}
return _switchWallet(context, selectedWallet, request, decision, fallbackCurrency);
}
Future<void> _pickDestinationWallet(
BuildContext pageContext,
AnyPayCrossChainPayment decision,
String networkName,
) async {
WalletInfo? destinationWalletInfo;
if (decision.wallets.length > 1) {
destinationWalletInfo = await SwitchNetworkWalletPage.push(
context: pageContext,
networkName: networkName,
targetIconPath: symbolIconPathForWalletType(decision.targetWalletType) ?? "",
wallets: decision.wallets,
);
} else {
destinationWalletInfo = decision.wallets.first;
}
if (destinationWalletInfo == null || !pageContext.mounted) {
return;
}
Navigator.of(pageContext).pop(destinationWalletInfo);
}
Future<AnyPayFormFill?> _switchWallet(
BuildContext context,
WalletInfo walletInfo,
AnyPayRequest request,
AnyPayCrossChainPayment decision,
CryptoCurrency? fallbackCurrency,
) async {
if (request.isLightning) {
_enterLightningMode();
}
_isSwitchingWallet = true;
BuildContext? loadingSheetContext;
bool completedFlow = false;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!context.mounted || completedFlow) {
return;
}
showModalBottomSheet<void>(
context: context,
isDismissible: false,
builder: (sheetContext) {
if (completedFlow) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (Navigator.canPop(sheetContext)) {
Navigator.of(sheetContext).pop();
}
});
return const SizedBox.shrink();
}
loadingSheetContext = sheetContext;
return LoadingBottomSheet(titleText: S.of(sheetContext).loading_your_wallet);
},
);
});
bool success = false;
try {
success = await anyPayService.switchWalletForPayment(
walletInfo,
chainId: decision.targetChainId,
);
} finally {
_isSwitchingWallet = false;
completedFlow = true;
if (loadingSheetContext != null &&
loadingSheetContext!.mounted &&
Navigator.canPop(loadingSheetContext!)) {
Navigator.of(loadingSheetContext!).pop();
}
loadingSheetContext = null;
}
if (!context.mounted) {
return null;
}
if (!success) {
_showError(context, S.of(context).network_switch_failed);
return null;
}
return AnyPayFormFill(
request: request,
token: decision.token,
fallbackCurrency: fallbackCurrency ?? request.detection.detectedCurrency,
amountOverride: decision.amountOverride,
);
}
Future<void> _openSwap(
BuildContext presentContext,
AnyPayRequest request,
AnyPayCrossChainPayment decision,
) async {
if (!presentContext.mounted) {
return;
}
if (request.hasContract && decision.token == null) {
_showError(presentContext, S.of(presentContext).unsupported_token_requested);
return;
}
final intent = anyPayService.buildSwapIntent(
request,
targetWalletType: decision.targetWalletType,
targetChainId: decision.targetChainId,
token: decision.token,
);
final page = NewSwapPage(
getIt.get<ExchangeViewModel>(),
authService,
getIt.get<AddressResolverService>(),
null,
walletSwitcherViewModel: walletSwitcherViewModel,
fromSend: SwapFromSendArgs.fromIntent(intent),
balanceViewModel: sendViewModel.balanceViewModel,
);
await Navigator.of(presentContext).push<void>(
CupertinoPageRoute(
builder: (context) => Material(
color: Theme.of(context).colorScheme.surface,
child: SafeArea(bottom: false, child: page),
),
),
);
}
void _showError(BuildContext context, String message) {
if (!context.mounted) {
return;
}
showPopUp<void>(
context: context,
builder: (context) => AlertWithOneAction(
alertTitle: S.of(context).error,
alertContent: message,
buttonText: S.of(context).ok,
buttonAction: () => Navigator.of(context).pop(),
),
);
}
void _enterLightningMode() {
sendViewModel.selectedCryptoCurrency = CryptoCurrency.btcln;
sendViewModel.coinTypeToSpendFrom = UnspentCoinType.lightning;
}
int _currentEvmChainId() {
final wallet = sendViewModel.wallet;
return evm!.getSelectedChainId(wallet) ?? evm!.getChainIdByWalletType(wallet.type);
}
}
@@ -0,0 +1,131 @@
import "package:hash_wallet/generated/i18n.dart";
import "package:hash_wallet/new-ui/widgets/anypay/select_recipient_network_sheet.dart";
import "package:hash_wallet/new-ui/widgets/receive_page/receive_top_bar.dart";
import "package:hash_wallet/src/widgets/hash_image_widget.dart";
import "package:flutter/material.dart";
class EvmAddressDetectedSheet extends StatelessWidget {
const EvmAddressDetectedSheet({super.key, required this.networks});
final List<RecipientNetworkItem> networks;
static Future<int?> show({
required BuildContext context,
required List<RecipientNetworkItem> networks,
}) {
return showModalBottomSheet<int>(
context: context,
isScrollControlled: true,
useSafeArea: true,
backgroundColor: Colors.transparent,
builder: (_) => EvmAddressDetectedSheet(networks: networks),
);
}
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
return Container(
decoration: BoxDecoration(
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
color: colors.surface,
),
child: SafeArea(
top: false,
child: Column(
children: [
ModalTopBar(
title: "",
trailingIcon: const Icon(Icons.close),
onTrailingPressed: () => Navigator.of(context).maybePop(),
),
const SizedBox(height: 32),
StackedNetworkIcons(iconPaths: networks.map((n) => n.iconPath).toList()),
const SizedBox(height: 24),
Text(
S.of(context).evm_address_detected,
textAlign: TextAlign.center,
style: Theme.of(context)
.textTheme
.titleLarge
?.copyWith(fontSize: 20, fontWeight: FontWeight.w500, letterSpacing: -0.1),
),
const SizedBox(height: 24),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 32),
child: Text(
S.of(context).select_recipient_network_for_tx,
textAlign: TextAlign.center,
style: Theme.of(context)
.textTheme
.bodyMedium
?.copyWith(color: colors.onSurfaceVariant, letterSpacing: -0.07),
),
),
const SizedBox(height: 64),
Flexible(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: NetworkListCard(
rows: [
for (final network in networks)
RecipientNetworkListRow(
item: network,
imageOverride: network.chainBadgeIconPath,
onTap: () => Navigator.of(context).pop(network.chainId),
),
],
),
),
),
const SizedBox(height: 16),
],
),
),
);
}
}
class StackedNetworkIcons extends StatelessWidget {
const StackedNetworkIcons({
required this.iconPaths,
this.size = 50,
this.step = 34,
});
final List<String> iconPaths;
final double size;
final double step;
@override
Widget build(BuildContext context) {
if (iconPaths.isEmpty) return const SizedBox.shrink();
final colors = Theme.of(context).colorScheme;
final width = size + (iconPaths.length - 1) * step;
return SizedBox(
height: size,
width: width,
child: Stack(
children: [
for (int i = 0; i < iconPaths.length; i++)
Positioned(
left: i * step,
child: Container(
width: size,
height: size,
padding: const EdgeInsets.all(2),
decoration: BoxDecoration(
shape: BoxShape.circle,
color: colors.surfaceContainer,
border: Border.all(color: colors.surface, width: 2),
),
child: HashImageWidget(imageUrl: iconPaths[i], fit: BoxFit.contain),
),
),
],
),
);
}
}
@@ -0,0 +1,174 @@
import "package:hash_wallet/generated/i18n.dart";
import "package:hash_wallet/new-ui/widgets/new_primary_button.dart";
import "package:hash_wallet/new-ui/widgets/receive_page/receive_top_bar.dart";
import "package:hash_wallet/src/widgets/hash_image_widget.dart";
import "package:cw_core/currency_for_wallet_type.dart";
import "package:flutter/material.dart";
class NetworkDecisionPage extends StatelessWidget {
const NetworkDecisionPage({
required this.title,
required this.description,
required this.destinationIconPath,
required this.primaryText,
required this.onPrimary,
required this.secondaryText,
this.currentIconPath,
this.primaryIconPath,
this.secondaryIconPath,
this.onSecondary,
super.key,
});
final String title;
final String description;
final String destinationIconPath;
final String? currentIconPath;
final String primaryText;
final VoidCallback onPrimary;
final String? primaryIconPath;
final String secondaryText;
final String? secondaryIconPath;
final VoidCallback? onSecondary;
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
final primaryIcon = primaryIconPath;
final secondaryIcon = secondaryIconPath;
return Material(
color: colors.surface,
child: SafeArea(
child: Column(
children: [
ModalTopBar(
title: "",
leadingIcon: const Icon(Icons.arrow_back_ios_new),
leadingSemanticLabel: S.of(context).seed_alert_back,
onLeadingPressed: () => Navigator.of(context).maybePop(),
),
Expanded(
child: Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ExcludeSemantics(
child: _DecisionHeader(
destinationIconPath: destinationIconPath,
currentIconPath: currentIconPath,
),
),
const SizedBox(height: 24),
Text(
title,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontSize: 20,
fontWeight: FontWeight.w500,
letterSpacing: -0.1,
),
),
const SizedBox(height: 16),
Text(
description,
textAlign: TextAlign.center,
style: Theme.of(context)
.textTheme
.bodyMedium
?.copyWith(color: colors.onSurfaceVariant, letterSpacing: -0.07),
),
],
),
),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
NewPrimaryButton(
onPressed: onPrimary,
image: primaryIcon != null
? HashImageWidget(
imageUrl: primaryIcon,
width: 24,
height: 24,
colorFilter: ColorFilter.mode(colors.onPrimary, BlendMode.srcIn),
)
: null,
text: primaryText,
color: colors.primary,
textColor: colors.onPrimary,
),
const SizedBox(height: 12),
NewPrimaryButton(
onPressed: onSecondary ?? () => Navigator.of(context).maybePop(),
image: secondaryIcon != null
? HashImageWidget(
imageUrl: secondaryIcon,
width: 24,
height: 24,
colorFilter: ColorFilter.mode(colors.primary, BlendMode.srcIn),
)
: null,
text: secondaryText,
color: colors.surfaceContainer,
textColor: colors.primary,
),
],
),
),
],
),
),
);
}
}
class _DecisionHeader extends StatelessWidget {
const _DecisionHeader({required this.destinationIconPath, this.currentIconPath});
final String destinationIconPath;
final String? currentIconPath;
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
final current = currentIconPath;
if (current == null) {
return HashImageWidget(
imageUrl: destinationIconPath,
width: 75,
height: 75,
fit: BoxFit.contain,
color: isMonochromeSymbolIcon(destinationIconPath) ? colors.primary : null,
);
}
return Row(
mainAxisSize: MainAxisSize.min,
spacing: 12,
children: [
HashImageWidget(
imageUrl: current,
width: 50,
height: 50,
fit: BoxFit.contain,
color: isMonochromeSymbolIcon(current) ? colors.primary : null,
),
Icon(Icons.arrow_forward, color: colors.primary, size: 28),
HashImageWidget(
imageUrl: destinationIconPath,
width: 50,
height: 50,
fit: BoxFit.contain,
color: isMonochromeSymbolIcon(destinationIconPath) ? colors.primary : null,
),
],
);
}
}
@@ -0,0 +1,74 @@
import "package:hash_wallet/generated/i18n.dart";
import "package:hash_wallet/src/widgets/hash_image_widget.dart";
import "package:cw_core/currency_for_wallet_type.dart";
import "package:flutter/material.dart";
class RecipientNetworkSelector extends StatelessWidget {
const RecipientNetworkSelector({
required this.networkName,
required this.networkIconPath,
required this.onTap,
super.key,
});
final String networkName;
final String networkIconPath;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
return Semantics(
button: true,
label: "${S.of(context).network_prefix_on} $networkName",
child: GestureDetector(
onTap: onTap,
child: ExcludeSemantics(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
S.of(context).network_prefix_on,
style: Theme.of(context)
.textTheme
.bodyMedium
?.copyWith(color: colors.onSurfaceVariant, letterSpacing: -0.07),
),
const SizedBox(width: 12),
HashImageWidget(
imageUrl: networkIconPath,
width: 16,
height: 16,
fit: BoxFit.cover,
color: isMonochromeSymbolIcon(networkIconPath) ? colors.primary : null,
),
const SizedBox(width: 8),
Text(
networkName,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w400,
letterSpacing: -0.07,
color: colors.primary,
),
),
const SizedBox(width: 8),
Container(
width: 28,
height: 28,
decoration: BoxDecoration(
color: colors.surfaceContainer,
shape: BoxShape.circle,
),
child: Icon(
Icons.keyboard_arrow_down,
size: 18,
color: colors.onSurfaceVariant,
),
),
],
),
),
),
);
}
}
@@ -0,0 +1,243 @@
import "package:hash_wallet/generated/i18n.dart";
import "package:hash_wallet/new-ui/widgets/receive_page/receive_top_bar.dart";
import "package:hash_wallet/src/widgets/hash_image_widget.dart";
import "package:cw_core/currency_for_wallet_type.dart";
import "package:flutter/material.dart";
class RecipientNetworkItem {
const RecipientNetworkItem({
required this.chainId,
required this.name,
required this.iconPath,
this.chainBadgeIconPath,
});
final int chainId;
final String name;
final String iconPath;
final String? chainBadgeIconPath;
}
class SelectRecipientNetworkSheet extends StatelessWidget {
const SelectRecipientNetworkSheet({
super.key,
required this.networks,
required this.currentChainId,
});
final List<RecipientNetworkItem> networks;
final int currentChainId;
static Future<int?> show({
required BuildContext context,
required List<RecipientNetworkItem> networks,
required int currentChainId,
}) {
return showModalBottomSheet<int>(
context: context,
isScrollControlled: true,
useSafeArea: true,
enableDrag: false,
isDismissible: false,
backgroundColor: Colors.transparent,
builder: (_) => SelectRecipientNetworkSheet(
networks: networks,
currentChainId: currentChainId,
),
);
}
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
RecipientNetworkItem? current;
final compatible = <RecipientNetworkItem>[];
for (final network in networks) {
if (network.chainId == currentChainId && current == null) {
current = network;
} else {
compatible.add(network);
}
}
return Container(
decoration: BoxDecoration(
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
color: colors.surface,
),
child: SafeArea(
top: false,
child: Column(
children: [
ModalTopBar(
title: "",
trailingIcon: const Icon(Icons.close),
onTrailingPressed: () => Navigator.of(context).maybePop(),
),
const SizedBox(height: 32),
HashImageWidget(
imageUrl: "assets/new-ui/network_cube.svg",
width: 75,
height: 75,
colorFilter: ColorFilter.mode(colors.onSurfaceVariant, BlendMode.srcIn),
),
const SizedBox(height: 24),
Text(
S.of(context).select_recipient_network,
textAlign: TextAlign.center,
style: Theme.of(context)
.textTheme
.titleLarge
?.copyWith(fontSize: 20, fontWeight: FontWeight.w500, letterSpacing: -0.1),
),
const SizedBox(height: 24),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 32),
child: Text(
S.of(context).select_recipient_network_description,
textAlign: TextAlign.center,
style: Theme.of(context)
.textTheme
.bodyMedium
?.copyWith(color: colors.onSurfaceVariant, letterSpacing: -0.07),
),
),
const SizedBox(height: 32),
Flexible(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (current != null) ...[
NetworkListCard(
rows: [
RecipientNetworkListRow(
item: current,
subtitle: S.of(context).current_network,
onTap: () => Navigator.of(context).pop(current!.chainId),
),
],
),
const SizedBox(height: 24),
],
if (compatible.isNotEmpty) ...[
Text(
S.of(context).compatible_networks,
style: Theme.of(context)
.textTheme
.bodySmall
?.copyWith(color: colors.onSurfaceVariant, letterSpacing: -0.06),
),
const SizedBox(height: 12),
NetworkListCard(
rows: [
for (final network in compatible)
RecipientNetworkListRow(
item: network,
onTap: () => Navigator.of(context).pop(network.chainId),
),
],
),
],
const SizedBox(height: 16),
],
),
),
),
],
),
),
);
}
}
class NetworkListCard extends StatelessWidget {
const NetworkListCard({required this.rows});
final List<Widget> rows;
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
return Container(
decoration: BoxDecoration(
color: colors.surfaceContainer,
borderRadius: BorderRadius.circular(20),
),
clipBehavior: Clip.antiAlias,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
for (var i = 0; i < rows.length; i++) ...[
rows[i],
if (i != rows.length - 1)
Divider(
height: 1,
thickness: 1,
color: colors.surfaceContainerHigh,
indent: 48,
endIndent: 12,
),
],
],
),
);
}
}
class RecipientNetworkListRow extends StatelessWidget {
const RecipientNetworkListRow(
{super.key, required this.item, required this.onTap, this.subtitle, this.imageOverride});
final String? subtitle;
final String? imageOverride;
final VoidCallback onTap;
final RecipientNetworkItem item;
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
return InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14),
child: Row(
children: [
HashImageWidget(
imageUrl: imageOverride ?? item.iconPath,
width: 24,
height: 24,
fit: BoxFit.cover,
color: isMonochromeSymbolIcon(imageOverride ?? item.iconPath) ? colors.primary : null,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 4,
children: [
Text(
item.name,
style: Theme.of(context)
.textTheme
.bodyMedium
?.copyWith(fontWeight: FontWeight.w400, letterSpacing: -0.07),
),
if (subtitle != null)
Text(
subtitle!,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colors.onSurfaceVariant, letterSpacing: -0.06, fontSize: 12),
),
],
),
),
Icon(Icons.chevron_right, size: 16, color: colors.onSurfaceVariant),
],
),
),
);
}
}
@@ -0,0 +1,108 @@
import "package:hash_wallet/generated/i18n.dart";
import "package:hash_wallet/new-ui/widgets/receive_page/receive_top_bar.dart";
import "package:hash_wallet/new-ui/widgets/send_page/l2_action_wallet_selector.dart";
import "package:hash_wallet/src/widgets/hash_image_widget.dart";
import "package:cw_core/currency_for_wallet_type.dart";
import "package:cw_core/wallet_info.dart";
import "package:flutter/cupertino.dart";
import "package:flutter/material.dart";
class SwitchNetworkWalletPage extends StatelessWidget {
const SwitchNetworkWalletPage({
required this.wallets,
required this.networkName,
required this.destinationIconPath,
super.key,
});
final String networkName;
final String destinationIconPath;
final List<WalletInfo> wallets;
static Future<WalletInfo?> push({
required BuildContext context,
required String networkName,
required String targetIconPath,
required List<WalletInfo> wallets,
}) =>
Navigator.of(context).push<WalletInfo>(
CupertinoPageRoute(
builder: (_) => SwitchNetworkWalletPage(
networkName: networkName,
destinationIconPath: targetIconPath,
wallets: wallets,
),
),
);
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
return Material(
color: colors.surface,
child: SafeArea(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
ModalTopBar(
title: "",
leadingIcon: const Icon(Icons.arrow_back_ios_new),
leadingSemanticLabel: S.of(context).seed_alert_back,
onLeadingPressed: () => Navigator.of(context).maybePop(),
),
const SizedBox(height: 124),
Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.arrow_forward, color: colors.primary, size: 32),
const SizedBox(width: 8),
HashImageWidget(
imageUrl: destinationIconPath,
width: 50,
height: 50,
fit: BoxFit.contain,
color: isMonochromeSymbolIcon(destinationIconPath) ? colors.primary : null,
),
],
),
const SizedBox(height: 24),
Text(
S.of(context).switch_to_x_wallet(networkName),
textAlign: TextAlign.center,
style: Theme.of(context)
.textTheme
.titleLarge
?.copyWith(fontSize: 20, fontWeight: FontWeight.w500, letterSpacing: -0.1),
),
const SizedBox(height: 24),
Text(
S.of(context).select_compatible_wallet,
textAlign: TextAlign.center,
style: Theme.of(context)
.textTheme
.bodyMedium
?.copyWith(color: colors.onSurfaceVariant, letterSpacing: -0.07),
),
const SizedBox(height: 24),
Expanded(
child: ListView.separated(
padding: const EdgeInsets.symmetric(horizontal: 16),
itemCount: wallets.length,
separatorBuilder: (_, __) => const SizedBox(height: 12),
itemBuilder: (_, index) {
final wallet = wallets[index];
return WalletRow(
currencyIconPath: getCryptoCurrencyIconForWalletListItem(wallet.type),
walletName: wallet.name,
onTap: () => Navigator.of(context).pop(wallet),
);
},
),
),
],
),
),
);
}
}
+71 -53
View File
@@ -1,3 +1,4 @@
import 'package:hash_wallet/generated/i18n.dart';
import 'package:hash_wallet/src/widgets/hash_image_widget.dart';
import 'package:hash_wallet/themes/core/theme_extension.dart';
import 'package:flutter/material.dart';
@@ -60,67 +61,78 @@ class AppsWidget extends StatelessWidget {
end: Alignment.bottomCenter,
),
),
child: TextButton(
onPressed: onTap,
style: TextButton.styleFrom(
shape: RoundedSuperellipseBorder(
borderRadius: BorderRadius.circular(18),
side: BorderSide(
width: 1.25, color: Theme.of(context).colorScheme.surfaceContainerHigh),
),
padding: EdgeInsets.all(24),
),
child: Column(
children: [
Row(
// The card is one node; the arrow glyph's "leaves the app" meaning
// becomes a hint instead of an unlabeled icon.
child: MergeSemantics(
child: Semantics(
hint: isLink == true ? S.of(context).opens_externally : null,
child: TextButton(
onPressed: onTap,
style: TextButton.styleFrom(
shape: RoundedSuperellipseBorder(
borderRadius: BorderRadius.circular(18),
side: BorderSide(
width: 1.25, color: Theme.of(context).colorScheme.surfaceContainerHigh),
),
padding: EdgeInsets.all(24),
),
child: Column(
children: [
Padding(
padding: const EdgeInsets.only(right: 20),
child: HashImageWidget(imageUrl: image, height: 54, width: 54),
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
spacing: 6.0,
Row(
children: [
Padding(
padding: const EdgeInsets.only(right: 20),
child: ExcludeSemantics(
child: HashImageWidget(imageUrl: image, height: 54, width: 54),
),
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
spacing: 6.0,
children: [
Text(
title,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurface,
fontWeight: FontWeight.w800,
fontSize: 20,
),
softWrap: true,
),
SizedBox(),
],
),
SizedBox(height: 5),
Text(
title,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurface,
fontWeight: FontWeight.w800,
fontSize: 20,
subTitle,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w500,
),
softWrap: true,
),
SizedBox(),
],
),
SizedBox(height: 5),
Text(
subTitle,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w500,
),
softWrap: true,
),
ExcludeSemantics(
child: Icon(
isLink == true ? Icons.arrow_outward : Icons.arrow_forward_ios,
color: Theme.of(context).colorScheme.onSurfaceVariant,
size: 20,
),
],
),
)
],
),
Icon(
isLink == true ? Icons.arrow_outward : Icons.arrow_forward_ios,
color: Theme.of(context).colorScheme.onSurfaceVariant,
size: 20,
)
if (hint != null) ...[
SizedBox(height: 10),
hint!,
]
],
),
if (hint != null) ...[
SizedBox(height: 10),
hint!,
]
],
),
),
),
),
@@ -128,10 +140,16 @@ class AppsWidget extends StatelessWidget {
Positioned(
top: 10,
right: 10,
child: IconButton(
icon: Icon(Icons.close),
onPressed: onClose,
//color: Theme.of(context).colorScheme.onSurface,
// Label the icon-only button without adding a visible tooltip.
child: MergeSemantics(
child: Semantics(
label: S.of(context).close,
child: IconButton(
icon: Icon(Icons.close),
onPressed: onClose,
//color: Theme.of(context).colorScheme.onSurface,
),
),
),
),
],
+54
View File
@@ -0,0 +1,54 @@
import "package:hash_wallet/src/widgets/hash_image_widget.dart";
import "package:flutter/material.dart";
class BorderedSvgIcon extends StatelessWidget {
const BorderedSvgIcon({
required this.iconPath,
required this.iconSize,
Color? iconColor,
Color? borderColor,
this.borderSize = 6,
super.key,
}) : _iconColor = iconColor,
_borderColor = borderColor;
final String iconPath;
final Color? _iconColor;
final double iconSize;
final Color? _borderColor;
final double borderSize;
@override
Widget build(BuildContext context) {
final iconColor = _iconColor ?? Theme.of(context).colorScheme.onSurface;
final borderColor = _borderColor ?? Theme.of(context).colorScheme.surface;
return SizedBox(
width: iconSize + borderSize * 2,
height: iconSize + borderSize * 2,
child: Stack(
alignment: Alignment.center,
children: [
HashImageWidget(
imageUrl: iconPath,
width: iconSize + borderSize * 2,
height: iconSize + borderSize * 2,
colorFilter: ColorFilter.mode(
borderColor,
BlendMode.srcIn,
),
),
HashImageWidget(
imageUrl: iconPath,
width: iconSize,
height: iconSize,
colorFilter: ColorFilter.mode(
iconColor,
BlendMode.srcIn,
),
),
],
),
);
}
}
@@ -88,7 +88,8 @@ class TransferHistoryRow extends StatelessWidget {
child: Stack(
children: [
HashImageWidget(
imageUrl: 'assets/images/crypto/${sourceChainName?.toLowerCase()}.webp',
imageUrl:
'assets/new-ui/crypto_full_icons/${sourceChainName?.toLowerCase()}.svg',
width: 28,
height: 28,
),
@@ -96,7 +97,8 @@ class TransferHistoryRow extends StatelessWidget {
top: 14,
left: 12,
child: HashImageWidget(
imageUrl: 'assets/images/crypto/${destChainName?.toLowerCase()}.webp',
imageUrl:
'assets/new-ui/crypto_full_icons/${destChainName?.toLowerCase()}.svg',
width: 28,
height: 28,
),
@@ -0,0 +1,154 @@
import "package:hash_wallet/di.dart";
import "package:hash_wallet/generated/i18n.dart";
import "package:hash_wallet/main.dart";
import "package:hash_wallet/new-ui/modal_navigator.dart";
import "package:hash_wallet/new-ui/pages/buy_sell/buy_sell_amount_page.dart";
import "package:hash_wallet/new-ui/viewmodels/charts/charts_bloc.dart";
import "package:hash_wallet/src/widgets/hash_image_widget.dart";
import "package:hash_wallet/themes/core/theme_extension.dart";
import "package:hash_wallet/view_model/buy/buy_sell_view_model.dart";
import "package:flutter/material.dart";
class BuySellSelectorModal extends StatelessWidget {
const BuySellSelectorModal({super.key});
@override
Widget build(BuildContext context) => Container(
decoration: BoxDecoration(
borderRadius: const BorderRadius.vertical(top: Radius.circular(18)),
gradient: LinearGradient(
colors: [
Theme.of(context).colorScheme.surface,
Theme.of(context).colorScheme.surfaceDim,
],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
),
),
child: SafeArea(
top: false,
child: Column(
spacing: 24,
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox.shrink(),
Text(
S.of(context).buy_or_sell,
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w500),
),
Text(
S.of(context).buy_or_sell_desc,
style:
TextStyle(fontSize: 12, color: Theme.of(context).colorScheme.onSurfaceVariant),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 18),
child: Column(
spacing: 12,
children: [
BuySellSelectorModalButton(
title: S.of(context).buy_crypto,
description: S.of(context).buy_crypto_desc,
iconPath: "assets/new-ui/plus.svg",
onTap: () => openBuySellPage(context, BuySellPageMode.buy),
),
BuySellSelectorModalButton(
title: S.of(context).sell_crypto,
description: S.of(context).sell_crypto_desc,
iconPath: "assets/new-ui/sell.svg",
onTap: () => openBuySellPage(context, BuySellPageMode.sell),
),
],
),
),
const SizedBox.shrink(),
],
),
),
);
void openBuySellPage(BuildContext context, BuySellPageMode mode) {
Navigator.of(context).pop();
final page = getIt.get<NewBuySellAmountPage>(param1: NewBuySellParams(mode: mode));
showModalBottomSheet(
useSafeArea: true,
isScrollControlled: true,
context: navigatorKey.currentContext!,
builder: (modalContext) => ModalNavigator(
rootPage: page,
parentContext: modalContext,
),
);
}
}
class BuySellSelectorModalButton extends StatelessWidget {
const BuySellSelectorModalButton({
required this.title,
required this.description,
required this.iconPath,
required this.onTap,
super.key,
});
final String title;
final String description;
final String iconPath;
final VoidCallback onTap;
@override
Widget build(BuildContext context) => GestureDetector(
onTap: onTap,
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
border: Border.all(
width: 1,
color: Theme.of(context).colorScheme.surfaceContainerHigh,
),
gradient: LinearGradient(
colors: [
context.customColors.cardGradientColorPrimary,
context.customColors.cardGradientColorSecondary,
],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
),
),
child: Padding(
padding: const EdgeInsets.all(24),
child: Row(
spacing: 20,
children: [
HashImageWidget(
imageUrl: iconPath,
width: 55,
height: 55,
colorFilter: ColorFilter.mode(
Theme.of(context).colorScheme.onSurfaceVariant,
BlendMode.srcIn,
),
),
Column(
spacing: 8,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(fontWeight: FontWeight.w500, fontSize: 16),
),
Text(
description,
style: TextStyle(
fontSize: 12,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
),
],
),
),
),
);
}
+40 -30
View File
@@ -5,6 +5,7 @@ import 'package:hash_wallet/generated/i18n.dart';
import 'package:hash_wallet/new-ui/widgets/new_primary_button.dart';
import 'package:hash_wallet/new-ui/widgets/receive_page/receive_top_bar.dart';
import 'package:hash_wallet/src/widgets/hash_image_widget.dart';
import 'package:hash_wallet/wallet_type_utils.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_svg/flutter_svg.dart';
@@ -40,15 +41,16 @@ class _ChangelogModalState extends State<ChangelogModal> {
}
void loadChangelog() async {
final fileName = isMoneroOnly ? "monerocom_changelog" : "changelog";
String lang = WidgetsBinding.instance.platformDispatcher.locale.languageCode;
if (lang == "und" ||
lang.isEmpty ||
!(await File("$changelogTextLocation/changelog_$lang.json").exists())) {
!(await File("$changelogTextLocation/${fileName}_$lang.json").exists())) {
lang = "en";
}
final List<dynamic> changelog =
jsonDecode(await rootBundle.loadString("$changelogTextLocation/changelog_$lang.json"))
jsonDecode(await rootBundle.loadString("$changelogTextLocation/${fileName}_$lang.json"))
as List<dynamic>;
for (final item in changelog) {
@@ -170,34 +172,42 @@ class ChangelogItemWidget extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Row(
mainAxisSize: MainAxisSize.min,
spacing: 12,
children: [
if (item.iconFilename.isNotEmpty)
HashImageWidget(
imageUrl: "$changelogIconLocation/${item.iconFilename}.svg",
height: 36,
width: 36,
colorFilter: ColorFilter.mode(Theme.of(context).colorScheme.primary, BlendMode.srcIn),
),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 4,
children: [
if (item.title.isNotEmpty)
Text(
item.title,
style: TextStyle(fontWeight: FontWeight.w500),
),
if (item.description.isNotEmpty)
Text(item.description,
style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant,
))
],
)
],
return Padding(
padding: EdgeInsets.symmetric(horizontal: 18),
child: Row(
mainAxisSize: MainAxisSize.min,
spacing: 12,
children: [
if (item.iconFilename.isNotEmpty)
HashImageWidget(
imageUrl: "$changelogIconLocation/${item.iconFilename}.svg",
height: 36,
width: 36,
colorFilter: ColorFilter.mode(Theme.of(context).colorScheme.primary, BlendMode.srcIn),
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 4,
children: [
if (item.title.isNotEmpty)
Text(
item.title,
style: TextStyle(fontWeight: FontWeight.w500),
),
if (item.description.isNotEmpty)
Text(
item.description,
style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
)
],
),
)
],
),
);
}
}
@@ -0,0 +1,228 @@
import "dart:async";
import "package:hash_wallet/generated/i18n.dart";
import "package:hash_wallet/new-ui/model/charts/util/price_change_data.dart";
import "package:hash_wallet/new-ui/model/charts/util/price_change_direction.dart";
import "package:hash_wallet/new-ui/viewmodels/charts/charts_bloc.dart";
import "package:hash_wallet/new-ui/widgets/charts_page/change_pill.dart";
import "package:hash_wallet/new-ui/widgets/charts_page/chart_modal.dart";
import "package:hash_wallet/new-ui/widgets/long_press_menu/long_press_footer.dart";
import "package:hash_wallet/new-ui/widgets/long_press_menu/long_press_menu.dart";
import "package:hash_wallet/new-ui/widgets/long_press_menu/long_press_popup.dart";
import "package:hash_wallet/src/widgets/hash_image_widget.dart";
import "package:hash_wallet/themes/core/theme_extension.dart";
import "package:cw_core/crypto_currency.dart";
import "package:flutter/material.dart";
import "package:flutter/services.dart";
import "package:flutter_bloc/flutter_bloc.dart";
class ChartsAssetGrid extends StatelessWidget {
const ChartsAssetGrid({required this.bloc, super.key});
final ChartsBloc bloc;
@override
Widget build(BuildContext context) => BlocBuilder<ChartsBloc, ChartsState>(
bloc: bloc,
builder: (context, state) {
if (state is ChartsStateWithData) {
final currencies = state.currencies;
return GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: currencies.length,
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
crossAxisSpacing: 10,
mainAxisSpacing: 10,
mainAxisExtent: 105,),
itemBuilder: (context, index) {
final curr = currencies[index];
return ChartsAssetCard(
bloc: bloc,
currency: curr,
price: state.priceFor(curr)?.toStringWithPrecision(fractionalDigits: 2) ?? "...",
ticker: state.fiatTicker,
changeData: state is ChartsLoaded ? state.changeDataFor(curr) : null,
favorite: curr == state.pinnedCurrency,
isSingleCurrency: state.hasSingleCurrency,
);
},);
} else {
return const SizedBox.shrink();
}
},
);
}
class ChartsAssetCard extends StatelessWidget {
const ChartsAssetCard(
{required this.bloc, super.key,
required this.currency,
required this.price,
required this.ticker,
this.changeData,
required this.favorite,
required this.isSingleCurrency,});
final CryptoCurrency currency;
final String price;
final String ticker;
final PriceChangeData? changeData;
final bool favorite;
final bool isSingleCurrency;
final ChartsBloc bloc;
String get displayPrice {
try {
final priceDouble = double.parse(price);
if (priceDouble > 10000) {
return priceDouble.toStringAsFixed(0);
} else {
return priceDouble.toStringAsFixed(2);
}
} catch (_) {
return price;
}
}
@override
Widget build(BuildContext context) => LongPressPopupBuilder(
popup: LongPressMenu(items: [
LongPressMenuItem(
label: S.of(context).favorite,
iconPath: "assets/new-ui/favorite.svg",
onSelected: () {
bloc.add(CurrencyPinned(currency: currency));
Navigator.of(context).pop();
},
color: favorite ? Theme.of(context).colorScheme.error : null,),
LongPressMenuItem(
label: S.of(context).remove,
iconPath: "assets/new-ui/address_hide.svg",
onSelected: () {
if (!isSingleCurrency) {
bloc.add(CurrencyRemoved(currency: currency));
Navigator.of(context).pop();
}
},
color: isSingleCurrency ? Theme.of(context).colorScheme.onSurfaceVariant : null,),
],),
footer:
isSingleCurrency ? LongPressFooter(text: S.of(context).cannot_remove_last_asset) : null,
child: GestureDetector(
onTap: () async {
unawaited(HapticFeedback.mediumImpact());
final res = await showModalBottomSheet(
isScrollControlled: true,
context: context,
builder: (context) => ChartModal(
bloc: bloc,
currency: currency,
isFavorite: favorite,
),);
if (res != null && res is bool && res) {
bloc.add(CurrencyPinned(currency: currency));
}
},
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
border: Border.all(
width: 1, color: Theme.of(context).colorScheme.surfaceContainerHighest,),
gradient: LinearGradient(colors: [
context.customColors.cardGradientColorPrimary,
context.customColors.cardGradientColorSecondary,
], begin: Alignment.topCenter, end: Alignment.bottomCenter,),),
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
spacing: 12,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
spacing: 5,
children: [
if (changeData != null)
RotatedBox(
quarterTurns:
changeData!.direction == PriceChangeDirection.up ? 0 : 2,
child: HashImageWidget(
imageUrl: "assets/new-ui/price_change_arrow.svg",
width: 8,
height: 8,
colorFilter: ColorFilter.mode(
changeData!.direction.color, BlendMode.srcIn,),
),
),
Text(
currency.title.toUpperCase(),
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w500),
),
],
),
Text(
currency.fullName ?? "",
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 12,
color: Theme.of(context).colorScheme.onSurfaceVariant,),
),
],
),
),
if (changeData != null)
ChangePill(
changePercentage: changeData!.percentage,
direction: changeData!.direction,),
],
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
spacing: 8,
children: [
HashImageWidget(
imageUrl: currency.iconPath,
width: 24,
height: 24,
),
Expanded(
child: FittedBox(
alignment: Alignment.centerRight,
fit: BoxFit.scaleDown,
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
spacing: 4,
children: [
Text(
displayPrice,
style: const TextStyle(fontWeight: FontWeight.w500, fontSize: 20),
),
Text(
ticker,
style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w500,
fontSize: 20,),
),
],
),
),
),
],
),
],
),
),
),
),
);
}
@@ -0,0 +1,87 @@
import "package:hash_wallet/generated/i18n.dart";
import "package:hash_wallet/new-ui/model/charts/util/price_data_sort_criteria.dart";
import "package:hash_wallet/new-ui/viewmodels/charts/charts_bloc.dart";
import "package:hash_wallet/new-ui/widgets/currency_picker/currency_picker_args.dart";
import "package:hash_wallet/new-ui/widgets/currency_picker/currency_picker_sheet.dart";
import "package:hash_wallet/new-ui/widgets/long_press_menu/long_press_menu.dart";
import "package:hash_wallet/new-ui/widgets/long_press_menu/long_press_popup.dart";
import "package:hash_wallet/new-ui/widgets/modern_button.dart";
import "package:cw_core/crypto_currency.dart";
import "package:flutter/material.dart";
import "package:flutter_bloc/flutter_bloc.dart";
class ChartsAssetGridHeader extends StatelessWidget {
const ChartsAssetGridHeader({
required this.bloc, super.key,
});
final ChartsBloc bloc;
@override
Widget build(BuildContext context) => BlocBuilder<ChartsBloc, ChartsState>(
bloc: bloc,
builder: (context, state) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
S.of(context).followed_assets,
style:
TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant, fontSize: 12),
),
Row(
spacing: 8,
children: [
ModernButton.svg(
size: 36,
iconSize: 16,
svgPath: "assets/new-ui/add.svg",
onPressed: () => _showCurrencyPicker(context, bloc),
),
LongPressPopupBuilder(
showOnTap: true,
popup: LongPressMenu(
items: PriceDataSortCriterium.all.map((item) {
final isSelected =
state is ChartsStateWithData && state.sortCriterium == item;
return LongPressMenuItem(
label: item.name,
iconPath: item.iconPath,
color: isSelected ? Theme.of(context).colorScheme.primary : null,
onSelected: () {
bloc.add(SortingCriteriumChanged(newCriterium: item));
Navigator.of(context).pop();
},
);
}).toList(),
),
child: ModernButton.svg(
size: 36,
iconSize: 12,
svgPath: "assets/new-ui/sort.svg",
onPressed: () {},
),
),
],
),
],
);
},
);
void _showCurrencyPicker(BuildContext context, ChartsBloc bloc) {
CurrencyPickerSheet.show(
context: context,
args: CurrencyPickerArgs(
items: CryptoCurrency.all,
onSelected: (item) {
bloc.add(CurrencyAdded(currency: item));
},
showStablesHeader: false,
symbolResolver: (item) => item.symbol,
),
);
}
}
@@ -0,0 +1,23 @@
import 'package:hash_wallet/new-ui/model/charts/util/price_change_data.dart';
import 'package:hash_wallet/new-ui/widgets/charts_page/change_pill.dart';
import 'package:flutter/material.dart';
class ChangeDisplay extends StatelessWidget {
const ChangeDisplay({super.key, required this.changeData, required this.ticker});
final String ticker;
final PriceChangeData changeData;
@override
Widget build(BuildContext context) => Row(
mainAxisSize: MainAxisSize.min,
spacing: 10,
children: [
Text(
"${changeData.direction.symbol}${ticker} ${changeData.amount.toStringWithPrecision(fractionalDigits: 2)}",
style: TextStyle(fontSize: 16, color: changeData.direction.color),
),
ChangePill(changePercentage: changeData.percentage, direction: changeData.direction)
],
);
}
@@ -0,0 +1,21 @@
import "package:hash_wallet/new-ui/model/charts/util/price_change_direction.dart";
import "package:flutter/material.dart";
class ChangePill extends StatelessWidget {
const ChangePill({super.key, required this.changePercentage, required this.direction});
final String changePercentage;
final PriceChangeDirection direction;
@override
Widget build(BuildContext context) => Container(
decoration: BoxDecoration(
color: direction.color.withAlpha(52), borderRadius: BorderRadius.circular(999999)),
child: Padding(
padding: const EdgeInsets.only(top: 2.5, bottom: 2.5, left: 4, right: 8),
child: Text(
"${direction.symbol} $changePercentage%",
style: TextStyle(color: direction.color),
),
));
}
@@ -0,0 +1,164 @@
import "package:hash_wallet/new-ui/viewmodels/charts/charts_bloc.dart";
import "package:hash_wallet/new-ui/widgets/charts_page/change_display.dart";
import "package:hash_wallet/new-ui/widgets/charts_page/chart_view.dart";
import "package:hash_wallet/new-ui/widgets/charts_page/coin_header.dart";
import "package:hash_wallet/new-ui/widgets/charts_page/price_header.dart";
import "package:hash_wallet/new-ui/widgets/charts_page/range_selector.dart";
import "package:hash_wallet/src/widgets/hash_image_widget.dart";
import "package:cw_core/amount/money_double.dart";
import "package:cw_core/crypto_currency.dart";
import "package:flutter/cupertino.dart";
import "package:flutter/material.dart";
import "package:flutter/services.dart";
import "package:flutter_bloc/flutter_bloc.dart";
import "package:intl/intl.dart";
class ChartHeader extends StatefulWidget {
const ChartHeader({
required this.bloc, super.key,
required this.currency,
required this.chartHeight,
required this.chartPadding,
required this.centered,
required this.favorite,
});
final CryptoCurrency currency;
final double chartHeight;
final double chartPadding;
final bool centered;
final bool favorite;
final ChartsBloc bloc;
@override
State<ChartHeader> createState() => _ChartHeaderState();
}
class _ChartHeaderState extends State<ChartHeader> {
String? _viewedPrice;
String? _viewedTime;
@override
Widget build(BuildContext context) => BlocBuilder<ChartsBloc, ChartsState>(
bloc: widget.bloc,
builder: (context, state) {
if (state case final ChartsStateWithData s) {
return Column(
crossAxisAlignment:
widget.centered ? CrossAxisAlignment.center : CrossAxisAlignment.start,
spacing: 10,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 18),
child: Column(
crossAxisAlignment:
widget.centered ? CrossAxisAlignment.center : CrossAxisAlignment.start,
spacing: 10,
children: [
if (!widget.favorite)
HashImageWidget(
imageUrl: widget.currency.iconPath ?? "",
width: 60,
height: 60,
),
ChartViewCoinHeader(currency: widget.currency, isFavorite: widget.favorite),
ChartViewPriceHeader(
price: _viewedPrice ?? s.priceFor(widget.currency)?.toStringWithPrecision(fractionalDigits: 2) ?? "...",
ticker: s.fiatTicker,
highlight: _viewedPrice != null,
),
Column(
crossAxisAlignment:
widget.centered ? CrossAxisAlignment.center : CrossAxisAlignment.start,
children: [
Container(
// has to be constant-size, otherwise will jump around when loading
height: 36,
alignment: widget.centered ? Alignment.center : Alignment.centerLeft,
child: (s is ChartsLoaded)
? (_viewedPrice != null && _viewedTime != null)
? Text(
_viewedTime!,
style: TextStyle(
fontFamily: "IBM Plex Mono",
fontWeight: FontWeight.w500,
color: Theme.of(context).colorScheme.onSurfaceVariant,
fontSize: 16,
),
)
: ChangeDisplay(
changeData: s.changeDataFor(widget.currency),
ticker: s.fiatTicker,
)
: const SizedBox.shrink(),
),
if (s is ChartsLoaded)
Padding(
padding: EdgeInsets.symmetric(vertical: widget.chartPadding),
child: PriceChart(
height: widget.chartHeight,
prices: s.dataFor(widget.currency),
direction: s.changeDataFor(widget.currency).direction,
touchCallback: (event, response) {
if (!event.isInterestedForInteractions) {
setState(() {
_viewedPrice = null;
_viewedTime = null;
});
return;
}
final newPrice =
response?.lineBarSpots?.firstOrNull?.y.toStringAsFixed(2);
final newTime = DateFormat("M/d/y HH:mm").format(
DateTime.fromMillisecondsSinceEpoch(
response?.lineBarSpots?.firstOrNull?.x.toInt() ?? 0,
),
);
if (_viewedPrice != newPrice) {
HapticFeedback.selectionClick();
setState(() {
_viewedPrice = newPrice;
});
}
if (_viewedTime != newTime) {
setState(() {
_viewedTime = newTime;
});
}
},
),
)
else
SizedBox(
height: widget.chartHeight + widget.chartPadding * 2,
child: const Center(child: CupertinoActivityIndicator()),
),
],
),
],
),
),
Container(
width: double.infinity,
height: 1,
color: Theme.of(context).colorScheme.onSurfaceVariant.withAlpha(128),
),
Row(
mainAxisSize: MainAxisSize.max,
mainAxisAlignment: MainAxisAlignment.center,
children: [
ChartRangeSelector(
selectedRange: s.range,
onRangeSelected: (range) =>
widget.bloc.add(RangeChanged(newRange: range)),
),
],
),
],
);
}
return const CupertinoActivityIndicator();
},
);
}
@@ -0,0 +1,156 @@
import "package:hash_wallet/di.dart";
import "package:hash_wallet/generated/i18n.dart";
import "package:hash_wallet/main.dart";
import "package:hash_wallet/new-ui/modal_navigator.dart";
import "package:hash_wallet/new-ui/pages/buy_sell/buy_sell_amount_page.dart";
import "package:hash_wallet/new-ui/pages/swap_page.dart";
import "package:hash_wallet/new-ui/viewmodels/charts/charts_bloc.dart";
import "package:hash_wallet/new-ui/widgets/charts_page/chart_header.dart";
import "package:hash_wallet/new-ui/widgets/coins_page/action_row/coin_action_button.dart";
import "package:hash_wallet/new-ui/widgets/receive_page/receive_top_bar.dart";
import "package:hash_wallet/routes.dart";
import "package:hash_wallet/src/screens/buy/buy_sell_page.dart";
import "package:hash_wallet/src/widgets/hash_image_widget.dart";
import "package:hash_wallet/store/app_store.dart";
import "package:hash_wallet/view_model/buy/buy_sell_view_model.dart";
import "package:cw_core/crypto_currency.dart";
import "package:flutter/material.dart";
import "package:flutter_bloc/flutter_bloc.dart";
import "package:modal_bottom_sheet/modal_bottom_sheet.dart";
class ChartModal extends StatelessWidget {
const ChartModal({required this.bloc, required this.currency, required this.isFavorite, super.key});
final CryptoCurrency currency;
final bool isFavorite;
final ChartsBloc bloc;
@override
Widget build(BuildContext context) => Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: const BorderRadius.vertical(top: Radius.circular(18)),
),
child: SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ModalTopBar(
title: "",
onLeadingPressed: Navigator.of(context).pop,
leadingIcon: const Icon(Icons.close),
padding: const EdgeInsets.only(top: 18, left: 18),
),
ChartHeader(
bloc: bloc,
currency: currency,
chartHeight: 220,
chartPadding: 32,
centered: true,
favorite: false,
),
const SizedBox(
height: 48,
),
Row(
mainAxisSize: MainAxisSize.max,
mainAxisAlignment: MainAxisAlignment.center,
spacing: MediaQuery.of(context).size.width * 0.05,
children: [
if (canOpenBuySell) ...[
CoinActionButton(
icon: HashImageWidget(
width: 36,
height: 36,
imageUrl: "assets/new-ui/plus.svg",
colorFilter: ColorFilter.mode(
Theme.of(context).colorScheme.primary,
BlendMode.srcIn,
),
),
label: S.of(context).buy,
action: () => openBuySellPage(context, BuySellPageMode.buy),
),
CoinActionButton(
icon: HashImageWidget(
width: 36,
height: 36,
imageUrl: "assets/new-ui/sell.svg",
colorFilter: ColorFilter.mode(
Theme.of(context).colorScheme.primary,
BlendMode.srcIn,
),
),
label: S.of(context).sell,
action: () => openBuySellPage(context, BuySellPageMode.sell),
),
],
CoinActionButton(
icon: HashImageWidget(
imageUrl: "assets/new-ui/exchange.svg",
colorFilter: ColorFilter.mode(
Theme.of(context).colorScheme.primary,
BlendMode.srcIn,
),
),
label: S.of(context).swap,
action: () {
final page = getIt.get<NewSwapPage>(param2: currency);
showCupertinoModalBottomSheet(
context: context,
barrierColor: Colors.black.withAlpha(85),
builder: (context) => Material(
child: ModalNavigator(
rootPage: page,
parentContext: context,
),
),
);
},
),
CoinActionButton(
icon: HashImageWidget(
width: 36,
height: 36,
imageUrl: "assets/new-ui/favorite.svg",
colorFilter: ColorFilter.mode(
isFavorite
? Theme.of(context).colorScheme.onSurface
: Theme.of(context).colorScheme.primary,
BlendMode.srcIn,
),
),
gradientColors:
isFavorite ? [const Color(0xFFDF2626), const Color(0xFF980F0F)] : null,
label: S.of(context).favorite,
action: () {
Navigator.of(context).pop(true);
},
),
],
),
const SizedBox(
height: 32,
),
],
),
),
);
void openBuySellPage(BuildContext context, BuySellPageMode mode) {
Navigator.of(context).pop();
final page = getIt.get<NewBuySellAmountPage>(
param1: NewBuySellParams(mode: mode, initialCurrency: currency));
showModalBottomSheet(
useSafeArea: true,
isScrollControlled: true,
context: navigatorKey.currentContext!,
builder: (modalContext) => ModalNavigator(
rootPage: page,
parentContext: modalContext,
),
);
}
bool get canOpenBuySell => getIt.get<AppStore>().wallet!.balance.containsKey(currency);
}
@@ -0,0 +1,70 @@
import "package:hash_wallet/new-ui/model/charts/price_data.dart";
import "package:hash_wallet/new-ui/model/charts/util/price_change_direction.dart";
import "package:fl_chart/fl_chart.dart";
import "package:flutter/material.dart";
class PriceChart extends StatelessWidget {
const PriceChart({
super.key,
required this.height,
required this.prices,
required this.direction,
required this.touchCallback,
});
final List<PriceData> prices;
final double height;
final PriceChangeDirection direction;
final Function(FlTouchEvent, LineTouchResponse?) touchCallback;
@override
Widget build(BuildContext context) {
final chartPoints = prices.map((entry) {
final x = entry.time.millisecondsSinceEpoch.toDouble();
final y = double.parse(entry.quote.toString());
return FlSpot(x, y);
}).toList();
return SizedBox(
height: height,
child: LineChart(
LineChartData(
gridData: const FlGridData(show: false),
titlesData: const FlTitlesData(show: false),
borderData: FlBorderData(show: false),
lineBarsData: [
LineChartBarData(
spots: chartPoints,
gradient: LinearGradient(colors: [direction.color.withAlpha(25), direction.color]),
barWidth: 1.5,
isStrokeCapRound: true,
dotData: const FlDotData(show: false),
belowBarData: BarAreaData(show: false),
),
],
lineTouchData: LineTouchData(
enabled: true,
getTouchLineStart: (barData, spotIndex) => -double.infinity,
getTouchLineEnd: (barData, spotIndex) => double.infinity,
getTouchedSpotIndicator: (barData, spotIndexes) => spotIndexes
.map(
(index) => TouchedSpotIndicatorData(
FlLine(
strokeWidth: 1,
color: direction.color.withAlpha(80),
dashArray: [4, 4],
),
const FlDotData(),
),
)
.toList(),
touchTooltipData: LineTouchTooltipData(
getTooltipItems: (touchedSpots) => touchedSpots.map((spot) => null).toList(),
),
touchCallback: touchCallback,
),
),
),
);
}
}
@@ -0,0 +1,67 @@
import "package:hash_wallet/src/widgets/hash_image_widget.dart";
import "package:cw_core/crypto_currency.dart";
import "package:flutter/material.dart";
class ChartViewCoinHeader extends StatelessWidget {
const ChartViewCoinHeader({required this.currency, required this.isFavorite, super.key});
final CryptoCurrency currency;
final bool isFavorite;
@override
Widget build(BuildContext context) => Row(
mainAxisAlignment: isFavorite ? MainAxisAlignment.spaceBetween : MainAxisAlignment.center,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.center,
spacing: 10,
children: [
if (isFavorite)
HashImageWidget(
imageUrl: currency.iconPath ?? "",
width: 30,
height: 30,
),
Row(
mainAxisAlignment: MainAxisAlignment.center,
spacing: 5,
children: [
Text(
currency.fullName ?? currency.title,
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.w500,
color: Theme.of(context).colorScheme.onSurface,
),
),
Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(9999999),
color: Theme.of(context).colorScheme.surfaceContainer,
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: Text(
currency.title,
style: TextStyle(
fontSize: 14,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
),
),
],
),
],
),
if (isFavorite)
HashImageWidget(
imageUrl: "assets/new-ui/favorite.svg",
width: 16,
height: 16,
colorFilter:
ColorFilter.mode(Theme.of(context).colorScheme.onSurfaceVariant, BlendMode.srcIn),
),
],
);
}
@@ -0,0 +1,43 @@
import "package:flutter/material.dart";
class ChartViewPriceHeader extends StatelessWidget {
const ChartViewPriceHeader({
required this.price,
required this.ticker,
required this.highlight,
super.key,
});
final String price;
final String ticker;
final bool highlight;
@override
Widget build(BuildContext context) => FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerLeft,
child: Row(
spacing: 6,
children: [
Text(
price,
style: TextStyle(
fontSize: 36,
color: highlight
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.onSurface,
),
),
Text(
ticker,
style: TextStyle(
fontSize: 36,
color: highlight
? Theme.of(context).colorScheme.primary.withAlpha(128)
: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
),
);
}
@@ -0,0 +1,72 @@
import "package:hash_wallet/new-ui/model/charts/util/chart_range.dart";
import "package:flutter/material.dart";
class ChartRangeSelector extends StatelessWidget {
const ChartRangeSelector({required this.selectedRange, required this.onRangeSelected, super.key});
final ChartRange selectedRange;
final Function(ChartRange) onRangeSelected;
static const double optionSize = 36;
static const double optionPadding = 24;
static const Duration switchDuration = Duration(milliseconds: 250);
double pillPosition(int selectedIndex) => selectedIndex * (optionSize + optionPadding);
@override
Widget build(BuildContext context) {
final selectedIndex = ChartRange.ranges.indexOf(selectedRange);
return Stack(
children: [
AnimatedPositioned(
curve: Curves.easeOutCubic,
left: pillPosition(selectedIndex),
duration: switchDuration,
child: Container(
height: optionSize,
width: optionSize,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.onSurface.withAlpha(25),
borderRadius: BorderRadius.circular(999999),
),
),
),
Row(
children: ChartRange.ranges.map((item) {
final selected = selectedRange == item;
final isFirst = ChartRange.ranges.indexOf(item) == 0;
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () {
if (!selected) {
onRangeSelected(item);
}
},
child: Padding(
padding: EdgeInsets.only(
right: optionPadding / 2,
left: isFirst ? 0 : optionPadding / 2,
),
child: SizedBox(
width: optionSize,
height: optionSize,
child: AnimatedDefaultTextStyle(
duration: switchDuration,
style: TextStyle(
fontWeight: selected ? FontWeight.w400 : FontWeight.w500,
color: selected
? Theme.of(context).colorScheme.onSurface
: Theme.of(context).colorScheme.onSurfaceVariant,
),
child: Center(child: Text(item.displayText)),
),
),
),
);
}).toList(),
),
],
);
}
}
@@ -18,6 +18,7 @@ class CoinControlListItem extends StatelessWidget {
required this.isLast,
required this.isLoading,
this.onCheckBoxTap,
required this.hasCheckbox,
});
final String note;
@@ -31,6 +32,7 @@ class CoinControlListItem extends StatelessWidget {
final bool isFirst;
final bool isLast;
final bool isLoading;
final bool hasCheckbox;
final Function()? onCheckBoxTap;
@override
@@ -133,6 +135,10 @@ class CoinControlListItem extends StatelessWidget {
return HashImageWidget(imageUrl: "assets/new-ui/frozen.svg");
}
if (!hasCheckbox) {
return SizedBox.shrink();
}
return NewSimpleCheckbox(
value: isSending,
onChanged: (value) {
@@ -0,0 +1,109 @@
import "package:hash_wallet/generated/i18n.dart";
import "package:hash_wallet/src/widgets/hash_image_widget.dart";
import "package:hash_wallet/store/settings_store.dart";
import "package:cw_core/wallet_type.dart";
import "package:flutter/material.dart";
import "package:flutter_mobx/flutter_mobx.dart";
class AccountsPromo extends StatelessWidget {
const AccountsPromo({
required this.settingsStore,
required this.walletName,
required this.onTap,
super.key,
});
final SettingsStore settingsStore;
final String walletName;
final VoidCallback onTap;
static bool supportsWallet(WalletType walletType) => walletType == WalletType.bitcoin;
@override
Widget build(BuildContext context) => Observer(
builder: (_) {
if (settingsStore.accountsHomePromoDismissed) {
return const SizedBox.shrink();
}
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
children: [
Material(
color: Theme.of(context).colorScheme.surfaceContainer,
borderRadius: BorderRadius.circular(18),
child: InkWell(
borderRadius: BorderRadius.circular(18),
onTap: onTap,
child: Container(
constraints: const BoxConstraints(minHeight: 64),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
border: Border.all(color: Theme.of(context).colorScheme.outlineVariant),
borderRadius: BorderRadius.circular(18),
),
child: Row(
children: [
const HashImageWidget(
imageUrl: "assets/new-ui/settings_row_icons/accounts.svg",
width: 24,
height: 24,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
S.of(context).accounts_home_promo_title(walletName),
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 2),
Text(
S.of(context).accounts_home_promo_description,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
),
),
const SizedBox(width: 8),
Icon(
Icons.arrow_forward_ios,
size: 16,
color: Theme.of(context).colorScheme.primary,
),
],
),
),
),
),
const SizedBox(height: 12),
Semantics(
button: true,
child: InkWell(
borderRadius: BorderRadius.circular(8),
onTap: () => settingsStore.accountsHomePromoDismissed = true,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: Text(
S.of(context).do_not_show_anymore,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.w500,
),
),
),
),
),
],
),
);
},
);
}
@@ -1,21 +1,22 @@
import 'dart:math';
import "dart:math";
import 'package:hash_wallet/themes/core/theme_extension.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_svg/svg.dart';
import "package:hash_wallet/themes/core/theme_extension.dart";
import "package:flutter/material.dart";
import "package:flutter/services.dart";
class CoinActionButton extends StatelessWidget {
const CoinActionButton({
super.key,
required this.icon,
required this.label,
required this.action,
super.key,
this.gradientColors,
});
final Widget icon;
final String label;
final VoidCallback action;
final List<Color>? gradientColors;
static const sizeFactor = 0.16;
@@ -24,47 +25,58 @@ class CoinActionButton extends StatelessWidget {
final size = MediaQuery.of(context).size.width * sizeFactor;
final double effectiveSize = min(size, 80);
return Column(
children: [
Container(
width: effectiveSize,
height: effectiveSize,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: LinearGradient(
colors: [
context.customColors.cardGradientColorPrimary,
context.customColors.cardGradientColorSecondary
],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
),
border: Border.all(
color: Theme.of(context).colorScheme.surfaceContainerHigh,
width: 1,
),
),
child: IconButton(
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
onPressed: () {
HapticFeedback.mediumImpact();
action();
},
icon: icon,
color: Theme.of(context).colorScheme.primary,
),
),
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Text(
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurface,
// The caption below the circle is the accessible name, so it is excluded
// from the semantics tree to keep this a single button node.
return MergeSemantics(
child: Semantics(
button: true,
label: label,
child: Column(
children: [
Container(
width: effectiveSize,
height: effectiveSize,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: LinearGradient(
colors: gradientColors ??
[
context.customColors.cardGradientColorPrimary,
context.customColors.cardGradientColorSecondary,
],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
),
label,
),
border: Border.all(
color: Theme.of(context).colorScheme.surfaceContainerHigh,
width: 1,
),
),
child: IconButton(
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
onPressed: () {
HapticFeedback.mediumImpact();
action();
},
icon: ExcludeSemantics(child: icon),
color: Theme.of(context).colorScheme.primary,
),
),
Padding(
padding: const EdgeInsets.only(top: 8),
child: ExcludeSemantics(
child: Text(
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
label,
),
),
),
],
),
],
),
);
}
}
@@ -17,14 +17,13 @@ import 'package:hash_wallet/view_model/send/send_view_model.dart';
import 'package:hash_wallet/view_model/wallet_address_list/wallet_address_list_view_model.dart';
import 'package:cw_core/crypto_currency.dart';
import 'package:cw_core/lnurl.dart';
import 'package:cw_core/node.dart';
import 'package:cw_core/unspent_coin_type.dart';
import 'package:cw_core/wallet_type.dart';
import 'package:flutter/material.dart';
import 'package:flutter_svg/svg.dart';
import 'package:modal_bottom_sheet/modal_bottom_sheet.dart';
import '../../../pages/receive_page.dart';
import '../../../pages/scan_page.dart';
import 'coin_action_button.dart';
class CoinActionRow extends StatelessWidget {
@@ -45,6 +44,7 @@ class CoinActionRow extends StatelessWidget {
spacing: MediaQuery.of(context).size.width * 0.05,
children: [
CoinActionButton(
key: ValueKey("home_page_send_button_key"),
icon: HashImageWidget(
imageUrl: "assets/new-ui/send.svg",
colorFilter: ColorFilter.mode(
@@ -54,7 +54,6 @@ class CoinActionRow extends StatelessWidget {
),
label: S.of(context).send,
action: () {
if (FeatureFlag.hasNewUiExtraPages) {
final sendPage = getIt.get<NewSendPage>(
param1: SendPageParams(
unspentCoinType:
@@ -75,14 +74,10 @@ class CoinActionRow extends StatelessWidget {
);
},
);
} else {
Map<String, dynamic>? args;
if (lightningMode) args = {'coinTypeToSpendFrom': UnspentCoinType.lightning};
Navigator.of(context).pushNamed(Routes.send, arguments: args);
}
},
),
CoinActionButton(
key: ValueKey("home_page_receive_button_key"),
icon: HashImageWidget(
imageUrl: "assets/new-ui/receive.svg",
colorFilter: ColorFilter.mode(
@@ -92,7 +87,6 @@ class CoinActionRow extends StatelessWidget {
),
label: S.of(context).receive,
action: () async {
if (FeatureFlag.hasNewUiExtraPages) {
final page = getIt.get<NewReceivePage>(param1: lightningMode);
CupertinoScaffold.showCupertinoModalBottomSheet(
context: context,
@@ -101,21 +95,11 @@ class CoinActionRow extends StatelessWidget {
return Material(child: ModalNavigator(parentContext: context, rootPage: page));
},
);
} else {
// ToDo: (Konsti) refactor as part of the derivation PR (I hate myself for it)
if (lightningMode) {
await getIt<WalletAddressListViewModel>().setAddressType(
bitcoin!.getOptionToType(bitcoin!.getBitcoinLightningReceivePageOption()));
} else {
await getIt<WalletAddressListViewModel>().setAddressType(
bitcoin!.getOptionToType(bitcoin!.getBitcoinSegwitPageOption()));
}
Navigator.of(context).pushNamed(Routes.addressPage);
}
},
),
if (showSwap)
CoinActionButton(
key: ValueKey("home_page_swap_button_key"),
icon: HashImageWidget(
imageUrl: "assets/new-ui/exchange.svg",
colorFilter: ColorFilter.mode(
@@ -127,7 +111,6 @@ class CoinActionRow extends StatelessWidget {
action: () {
final page =
getIt.get<NewSwapPage>(param2: lightningMode ? CryptoCurrency.btcln : null);
if (FeatureFlag.hasNewUiExtraPages) {
CupertinoScaffold.showCupertinoModalBottomSheet(
context: context,
barrierColor: Colors.black.withAlpha(85),
@@ -137,12 +120,10 @@ class CoinActionRow extends StatelessWidget {
parentContext: context,
)),
);
} else {
Navigator.of(context).pushNamed(Routes.exchange);
}
},
),
CoinActionButton(
key: ValueKey("home_page_scan_button_key"),
icon: HashImageWidget(
imageUrl: "assets/new-ui/scan.svg",
colorFilter: ColorFilter.mode(
@@ -159,71 +140,63 @@ class CoinActionRow extends StatelessWidget {
}
Future<void> _onPressedScan(BuildContext context) async {
if (false && FeatureFlag.hasNewUiExtraPages) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (context) => FractionallySizedBox(
heightFactor: 0.9,
child: ScanPage(),
),
);
} else {
final code = await presentQRScanner(context);
final code = await presentQRScanner(context, showHelp: true);
if (code == null || code.isEmpty) return;
if (code == null || code.isEmpty) return;
late final PaymentRequest req;
var unspentCoinType = UnspentCoinType.any;
if (SendViewModelBase.isNonZeroAmountLightningInvoice(code)) {
unspentCoinType = UnspentCoinType.lightning;
final amount = CryptoCurrency.btcln.formatAmount(BigInt.from(getBolt11Amount(code) ?? 0));
req = PaymentRequest(code, amount, "", "", "");
} else if (SendViewModelBase.isLnurlInvoice(code)) {
unspentCoinType = UnspentCoinType.lightning;
final amount = CryptoCurrency.btcln
.formatAmount(BigInt.from(await LNURL.getPayRequestAmount(code) ?? 0));
req = PaymentRequest(code, amount, "", "", "");
} else if (OpenCryptoPayService.isOpenCryptoPayQR(code)) {
req = PaymentRequest(code, "", "", "", "");
} else if (Uri.tryParse(code)?.scheme == "wc") {
if (!isEVMCompatibleChain(walletType)) {
showPopUp<void>(
context: context,
builder: (context) => AlertWithOneAction(
alertTitle: "WalletConnect",
alertContent: S.of(context).switchToEVMCompatibleWallet,
buttonText: "OK",
buttonAction: Navigator.of(context).pop));
return;
}
Navigator.of(context)
.pushNamed(Routes.walletConnectConnectionsListing, arguments: Uri.parse(code));
PaymentRequest? req;
String? rawInput;
var unspentCoinType = UnspentCoinType.any;
if (SendViewModelBase.isNonZeroAmountLightningInvoice(code)) {
unspentCoinType = UnspentCoinType.lightning;
final amount = getBolt11Amount(code)?.toString() ?? "0";
req = PaymentRequest(code, amount, "", "", "");
} else if (SendViewModelBase.isLnurlInvoice(code)) {
unspentCoinType = UnspentCoinType.lightning;
final amount = (await LNURL.getPayRequestAmount(code))?.toString() ?? "0";
req = PaymentRequest(code, amount, "", "", "");
} else if (Uri.tryParse(code)?.scheme == "wc") {
if (!isWalletConnectCompatibleChain(walletType)) {
showPopUp<void>(
context: context,
builder: (context) => AlertWithOneAction(
alertTitle: "WalletConnect",
alertContent:
S.of(context).switchToWCCompatibleWallet(walletConnectCompatibleChainsLabel()),
buttonText: "OK",
buttonAction: Navigator.of(context).pop));
return;
} else {
final uri = Uri.tryParse(code);
if (uri == null) return;
req = PaymentRequest.fromUri(uri);
}
final sendPage = getIt.get<NewSendPage>(
param1: SendPageParams(
initialPaymentRequest: req,
unspentCoinType: unspentCoinType,
),
);
CupertinoScaffold.showCupertinoModalBottomSheet(
context: context,
barrierColor: Colors.black.withAlpha(60),
builder: (context) => Material(
child: ModalNavigator(
rootPage: sendPage,
parentContext: context,
),
),
);
Navigator.of(context)
.pushNamed(Routes.walletConnectConnectionsListing, arguments: Uri.parse(code));
return;
} else if (!OpenCryptoPayService.isOpenCryptoPayQR(code) &&
["http", "https", "tcp"].contains(Uri.tryParse(code)?.scheme)) {
Navigator.of(context).pushNamed(Routes.newNode,
arguments: {"editingNode": Node.fromUri(Uri.parse(code), walletType)});
return;
} else {
rawInput = code;
}
final sendPage = getIt.get<NewSendPage>(
param1: SendPageParams(
initialPaymentRequest: req,
initialRawInput: rawInput,
unspentCoinType: unspentCoinType,
),
);
CupertinoScaffold.showCupertinoModalBottomSheet(
context: context,
barrierColor: Colors.black.withAlpha(60),
builder: (context) => Material(
child: ModalNavigator(
rootPage: sendPage,
parentContext: context,
),
),
);
}
}
@@ -6,6 +6,7 @@ import 'package:hash_wallet/new-ui/pages/bridge/bridge_amount_page.dart';
import 'package:hash_wallet/new-ui/pages/receive_page.dart';
import 'package:hash_wallet/new-ui/pages/send_page.dart';
import 'package:hash_wallet/new-ui/pages/swap_page.dart';
import 'package:hash_wallet/new-ui/widgets/coins_page/token_image_widget.dart';
import 'package:hash_wallet/new-ui/widgets/modern_button.dart';
import 'package:hash_wallet/new-ui/widgets/receive_page/receive_top_bar.dart';
import 'package:hash_wallet/src/widgets/hash_image_widget.dart';
@@ -61,6 +62,7 @@ class AssetDetailsModal extends StatelessWidget {
ModalTopBar(
title: "",
trailingIcon: Icon(Icons.close),
trailingSemanticLabel: S.of(context).close,
onTrailingPressed: Navigator.of(context).pop,
padding: EdgeInsets.only(top: 12, right: 18),
),
@@ -77,7 +79,10 @@ class AssetDetailsModal extends StatelessWidget {
child: Stack(
children: [
if (iconPath.isNotEmpty)
HashImageWidget(imageUrl: iconPath, width: 75, height: 75)
TokenImageWidget(
imageUrl: iconPath,
size: 75,
)
else
Container(
width: 75,
@@ -352,17 +357,22 @@ class AssetDetailsModalBottomButton extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Column(
spacing: 8,
children: [
ModernButton.svg(
onPressed: onPressed,
svgPath: iconPath,
size: 60,
iconSize: 32,
),
Text(title)
],
// The caption is the button's accessible name, so it is excluded from the
// semantics tree to keep the whole control a single button node.
return MergeSemantics(
child: Column(
spacing: 8,
children: [
ModernButton.svg(
onPressed: onPressed,
svgPath: iconPath,
size: 60,
iconSize: 32,
semanticLabel: title,
),
ExcludeSemantics(child: Text(title))
],
),
);
}
}
@@ -1,11 +1,11 @@
import 'dart:math' show min;
import 'package:hash_wallet/new-ui/widgets/coins_page/assets_history/asset_details_modal.dart';
import 'package:hash_wallet/new-ui/widgets/coins_page/token_image_widget.dart';
import 'package:hash_wallet/src/screens/wallet_connect/utils/string_parsing.dart';
import 'package:hash_wallet/src/widgets/hash_image_widget.dart';
import 'package:hash_wallet/view_model/dashboard/balance_view_model.dart';
import 'package:cw_core/crypto_currency.dart';
import 'package:cw_core/wallet_base.dart';
import 'package:cw_core/wallet_type.dart';
import 'package:flutter/material.dart';
class AssetTile extends StatelessWidget {
@@ -37,147 +37,140 @@ class AssetTile extends StatelessWidget {
@override
Widget build(BuildContext context) {
final iconPath = _getIconPath();
final iconPath = balance.asset.iconPath ?? "";
return GestureDetector(
onTap: () {
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (context) {
return AssetDetailsModal(
showSwap: showSwap,
showBridgeButton: showBridgeButton,
asset: balance.asset,
title: title ?? balance.asset.fullName ?? balance.asset.name,
chainTitle: "",
subtitle: trailingText ?? _getChainTitle(),
amount: showSecondary ? balance.secondAvailableBalance : balance.availableBalance,
currencyTitle: balance.asset.title,
fiatAmount: showSecondary
? balance.fiatSecondAvailableBalance
: balance.fiatAvailableBalance,
iconPath: balance.asset.iconPath ?? "",
chainIconPath: chainIconPath,
mode: modalMode,
wallet: wallet,
);
});
},
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 18.0),
child: Container(
width: double.infinity,
height: 72,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainer,
borderRadius: BorderRadius.vertical(
top: isFirst ? Radius.circular(18) : Radius.zero,
bottom: isLast ? Radius.circular(18) : Radius.zero,
),
),
// The row is one control: name, amount and fiat value merge into a single
// button node that opens the asset details sheet.
return MergeSemantics(
child: Semantics(
button: true,
child: GestureDetector(
onTap: () {
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (context) {
return AssetDetailsModal(
showSwap: showSwap,
showBridgeButton: showBridgeButton,
asset: balance.asset,
title: title ?? balance.asset.fullName ?? balance.asset.name,
chainTitle: "",
subtitle: trailingText ?? _getChainTitle(),
amount:
showSecondary ? balance.secondAvailableBalance : balance.availableBalance,
currencyTitle: balance.asset.title,
fiatAmount: showSecondary
? balance.fiatSecondAvailableBalance
: balance.fiatAvailableBalance,
iconPath: balance.asset.iconPath ?? "",
chainIconPath: chainIconPath,
mode: modalMode,
wallet: wallet,
);
});
},
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12.0),
child: Row(
mainAxisSize: MainAxisSize.max,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 36,
height: 36,
child: Stack(
children: [
if ((iconPath).isNotEmpty)
HashImageWidget(imageUrl: iconPath)
else
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
padding: const EdgeInsets.symmetric(horizontal: 18.0),
child: Container(
width: double.infinity,
height: 72,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainer,
borderRadius: BorderRadius.vertical(
top: isFirst ? Radius.circular(18) : Radius.zero,
bottom: isLast ? Radius.circular(18) : Radius.zero,
),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12.0),
child: Row(
mainAxisSize: MainAxisSize.max,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
// Decorative: the asset name is already in the row text.
ExcludeSemantics(
child: iconPath.isNotEmpty
? TokenImageWidget(
imageUrl: iconPath,
size: 36,
)
: Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary,
borderRadius: BorderRadius.circular(99999)),
child: Center(
shape: BoxShape.circle,
),
child: Center(
child: Text(
balance.asset.name
.substring(0, min(2, balance.asset.name.length)),
style: TextStyle(
fontSize: 20,
color: Theme.of(context).colorScheme.onPrimary),
)),
),
// if (chainIconPath.isNotEmpty)
// Align(
// alignment: Alignment.bottomRight,
// child: Container(
// decoration: ShapeDecoration(
// shape: RoundedSuperellipseBorder(
// borderRadius: BorderRadius.circular(5),side: BorderSide(color: Colors.black)),
// color: Colors.white),
// child: Padding(
// padding: const EdgeInsets.all(2.0),
// child: HashImageWidget(
// imageUrl: chainIconPath,
// width: 12,
// height: 12,
// colorFilter:
// ColorFilter.mode(Colors.black, BlendMode.srcIn),
// ),
// )))
],
)),
SizedBox(width: 12.0),
Expanded(
child: Column(
spacing: 4.0,
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
spacing: 4,
children: [
Text(
title ?? balance.asset.fullName ?? balance.asset.name,
style: TextStyle(fontWeight: FontWeight.w500),
),
if (trailingText != null)
Text(
trailingText!,
style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant),
balance.asset.name
.substring(0, min(2, balance.asset.name.length)),
style: TextStyle(
fontSize: 20,
color: Theme.of(context).colorScheme.onPrimary,
),
),
),
),
),
SizedBox(width: 12.0),
Expanded(
child: Column(
spacing: 4.0,
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
spacing: 4,
children: [
Text(
title ?? balance.asset.fullName ?? balance.asset.name,
style: TextStyle(fontWeight: FontWeight.w500),
),
if (trailingText != null)
Text(
trailingText!,
style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant),
),
],
),
Padding(
padding: const EdgeInsets.only(right: 4.0),
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerLeft,
child: Text(
"${showSecondary ? balance.secondAvailableBalance : balance.availableBalance} ${balance.formattedAssetTitle.safeSubString(0, 6)}",
maxLines: 1,
style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
),
),
],
),
Padding(
padding: const EdgeInsets.only(right: 4.0),
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerLeft,
child: Text(
"${showSecondary ? balance.secondAvailableBalance : balance.availableBalance} ${balance.formattedAssetTitle.safeSubString(0, 6)}",
maxLines: 1,
style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
),
),
],
),
),
],
),
],
),
),
Text(
showSecondary
? balance.fiatSecondAvailableBalance
: balance.fiatAvailableBalance,
style: TextStyle(
color: Theme.of(context).colorScheme.onSurface,
),
),
],
),
Text(
showSecondary ? balance.fiatSecondAvailableBalance : balance.fiatAvailableBalance,
style: TextStyle(
color: Theme.of(context).colorScheme.onSurface,
),
),
],
),
),
),
),
@@ -185,17 +178,5 @@ class AssetTile extends StatelessWidget {
);
}
String _getChainTitle() {
try {
return CryptoCurrency.fromString(wallet.currency.tag ?? wallet.currency.title).fullName ?? "";
} catch (e) {
return wallet.currency.fullName ?? "";
}
}
String _getIconPath() {
if (balance.asset == CryptoCurrency.baseEth) return "assets/new-ui/crypto_full_icons/base.svg";
return balance.asset.iconPath ?? "";
}
String _getChainTitle() => walletTypeToDisplayName(wallet.type);
}
@@ -1,19 +1,34 @@
import 'package:hash_wallet/generated/i18n.dart';
import 'package:hash_wallet/new-ui/modal_navigator.dart';
import 'package:hash_wallet/new-ui/widgets/coins_page/assets_history/assets_top_bar.dart';
import 'package:hash_wallet/new-ui/widgets/coins_page/assets_history/history_modal.dart';
import 'package:hash_wallet/new-ui/widgets/coins_page/assets_history/history_top_bar.dart';
import 'package:hash_wallet/reactions/wallet_connect.dart';
import 'package:hash_wallet/routes.dart';
import 'package:hash_wallet/src/screens/dashboard/pages/nft_listing_page.dart';
import 'package:hash_wallet/view_model/dashboard/dashboard_view_model.dart';
import 'package:hash_wallet/view_model/dashboard/nft_view_model.dart';
import 'package:flutter/material.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:mobx/mobx.dart';
import 'package:modal_bottom_sheet/modal_bottom_sheet.dart';
import 'assets_section.dart';
import 'history_section.dart';
class AssetsHistorySectionActionButton {
final String title;
final String iconPath;
final VoidCallback onPressed;
const AssetsHistorySectionActionButton(this.title, this.iconPath, this.onPressed);
}
class AssetsHistorySectionTab {
final String title;
final Widget content;
final AssetsHistorySectionActionButton? actionButton;
AssetsHistorySectionTab(this.title, this.content);
const AssetsHistorySectionTab(this.title, this.content, this.actionButton);
}
class AssetsHistorySection extends StatefulWidget {
@@ -32,22 +47,40 @@ class _AssetsHistorySectionState extends State<AssetsHistorySection> {
void reloadTabs() {
final oldTabLength = tabs.length;
final hasAssetsTab = widget.dashboardViewModel.balanceViewModel.isHomeScreenSettingsEnabled ||
(widget.dashboardViewModel.hasMweb && widget.dashboardViewModel.mwebEnabled);
final hasAssetsButton = widget.dashboardViewModel.balanceViewModel.isHomeScreenSettingsEnabled;
final hasNftTab = isNFTACtivatedChain(widget.dashboardViewModel.wallet.type,
chainId: widget.dashboardViewModel.wallet.chainId);
tabs = [
if (widget.dashboardViewModel.balanceViewModel.isHomeScreenSettingsEnabled ||
(widget.dashboardViewModel.hasMweb && widget.dashboardViewModel.mwebEnabled))
if (hasAssetsTab)
AssetsHistorySectionTab(
S.current.assets,
AssetsSection(
dashboardViewModel: widget.dashboardViewModel,
)),
),
hasAssetsButton ? AssetsHistorySectionActionButton(S.current.tokens, "assets/new-ui/options_slider.svg",
() {Navigator.of(context).pushNamed(
Routes.homeSettings,
arguments: widget.dashboardViewModel.balanceViewModel,
);
}): null),
AssetsHistorySectionTab(
S.current.history,
HistorySection(
detailsAsPage: false,
roundedTopSection: hasAssetsTab || hasNftTab,
dashboardViewModel: widget.dashboardViewModel,
)),
if (isNFTACtivatedChain(widget.dashboardViewModel.wallet.type,
chainId: widget.dashboardViewModel.wallet.chainId))
AssetsHistorySectionTab(S.current.nfts, NFTListingPage(nftViewModel: widget.nftViewModel))
short: true,
),
AssetsHistorySectionActionButton(
S.current.all_pascal_case, "assets/new-ui/arrow_right.svg", () {
openHistoryModal(context);
})),
if (hasNftTab)
AssetsHistorySectionTab(
S.current.nfts, NFTListingPage(nftViewModel: widget.nftViewModel), null)
];
if (oldTabLength != tabs.length) {
setState(() {
@@ -71,19 +104,39 @@ class _AssetsHistorySectionState extends State<AssetsHistorySection> {
Widget build(BuildContext context) {
return SliverMainAxisGroup(
slivers: [
// if(tabs.length>1)
AssetsTopBar(
dashboardViewModel: widget.dashboardViewModel,
tabs: tabs.map((item) => item.title).toList(),
onTabChange: (index) {
setState(() {
_selectedTab = index;
});
},
selectedTab: _selectedTab,
),
if (tabs.length > 1)
AssetsTopBar(
onTransactionHistoryOpened: () => openHistoryModal(context),
dashboardViewModel: widget.dashboardViewModel,
tabs: tabs,
onTabChange: (index) {
setState(() {
_selectedTab = index;
});
},
selectedTab: _selectedTab,
),
if (tabs.length == 1)
Observer(
builder: (_) => HistoryTopBar(
onTap: () => openHistoryModal(context),
roundedBottom: widget.dashboardViewModel.itemsShort.isEmpty,
),
),
tabs[_selectedTab].content
],
);
}
Future<void> openHistoryModal(BuildContext context) async {
await CupertinoScaffold.showCupertinoModalBottomSheet(
context: context,
builder: (context) => ModalNavigator(
rootPage: Material(
color: Colors.transparent,
child: HistoryModal(dashboardViewModel: widget.dashboardViewModel)),
parentContext: context,
));
widget.dashboardViewModel.changeAllFilterItems(true);
}
}
@@ -1,8 +1,5 @@
import 'package:hash_wallet/generated/i18n.dart';
import 'package:hash_wallet/new-ui/widgets/coins_page/assets_history/assets_history_section.dart';
import 'package:hash_wallet/new-ui/widgets/line_tab_switcher.dart';
import 'package:hash_wallet/routes.dart';
import 'package:hash_wallet/src/screens/dashboard/widgets/filter_widget.dart';
import 'package:hash_wallet/utils/show_pop_up.dart';
import 'package:hash_wallet/src/widgets/hash_image_widget.dart';
import 'package:hash_wallet/view_model/dashboard/dashboard_view_model.dart';
import 'package:flutter/material.dart';
@@ -11,78 +8,99 @@ class AssetsTopBar extends StatelessWidget {
const AssetsTopBar({
super.key,
required this.onTabChange,
required this.onTransactionHistoryOpened,
required this.selectedTab,
required this.tabs,
required this.dashboardViewModel,
});
final void Function(int) onTabChange;
final VoidCallback onTransactionHistoryOpened;
final int selectedTab;
final List<String> tabs;
final List<AssetsHistorySectionTab> tabs;
final DashboardViewModel dashboardViewModel;
@override
Widget build(BuildContext context) {
final settingsButtonText = _getSettingsButtonText();
final hasTokenSettingsButton = settingsButtonText != null;
final actionButton = tabs[selectedTab].actionButton;
return SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.only(top: 32.0, bottom: 0.0, left: 12.0, right: 18.0),
padding: const EdgeInsets.only(
top: 20,
bottom: 0,
left: 12,
right: 18,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
if (tabs.length > 1)
LineTabSwitcher(
tabs: tabs,
onTabChange: onTabChange,
selectedTab: selectedTab,
)
else
SizedBox.shrink(),
Opacity(
opacity: hasTokenSettingsButton ? 1 : 0,
child: ElevatedButton(
onPressed: () {
if (tabs[selectedTab] == S.of(context).assets) {
Navigator.of(context).pushNamed(
Routes.homeSettings,
arguments: dashboardViewModel.balanceViewModel,
);
} else if (tabs[selectedTab] == S.of(context).history) {
showPopUp<void>(
context: context,
builder: (context) =>
FilterWidget(filterItems: dashboardViewModel.filterItems),
);
}
},
style: ElevatedButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(999999),
),
backgroundColor: Theme.of(
context,
).colorScheme.surfaceContainer,
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12.0),
child: Row(
spacing: 6,
children: [
HashImageWidget(
imageUrl: "assets/new-ui/options_slider.svg",
colorFilter: ColorFilter.mode(
Theme.of(context).colorScheme.primary, BlendMode.srcIn)),
Text(
settingsButtonText ?? "",
style: TextStyle(color: Theme.of(context).colorScheme.primary),
)
],
),
),
LineTabSwitcher(
tabs: tabs.map((item) => item.title).toList(),
onTabChange: onTabChange,
selectedTab: selectedTab,
),
AnimatedSwitcher(
duration: Duration(milliseconds: 300),
layoutBuilder: (currentChild, previousChildren) {
return Stack(
alignment: Alignment.centerRight,
children: <Widget>[
...previousChildren,
if (currentChild != null) currentChild,
],
);
},
child: Row(
key: ValueKey(selectedTab),
spacing: 8,
children: [
// Built conditionally rather than rendered at opacity 0: an
// invisible chip stayed focusable for screen readers. The
// SizedBox keeps the 40px header height the invisible chip
// used to occupy.
if (actionButton == null)
const SizedBox(height: 40)
else
MergeSemantics(
child: Semantics(
button: true,
child: GestureDetector(
key: const ValueKey("assets_history_action_button_key"),
onTap: actionButton.onPressed,
child: Container(
height: 40,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(999999),
color: Theme.of(context).colorScheme.surfaceContainer,
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12.0),
child: Row(
spacing: 6,
children: [
if (actionButton.title.isNotEmpty)
Text(
actionButton.title,
style:
TextStyle(color: Theme.of(context).colorScheme.primary),
),
ExcludeSemantics(
child: HashImageWidget(
imageUrl: actionButton.iconPath,
colorFilter: ColorFilter.mode(
Theme.of(context).colorScheme.primary,
BlendMode.srcIn)),
),
],
),
),
),
),
),
),
],
),
),
],
@@ -90,16 +108,4 @@ class AssetsTopBar extends StatelessWidget {
),
);
}
String? _getSettingsButtonText() {
if (tabs[selectedTab] == S.current.assets &&
dashboardViewModel.balanceViewModel.isHomeScreenSettingsEnabled) {
return S.current.tokens;
}
if (tabs[selectedTab] == S.current.history) {
return S.current.filters;
}
return null;
}
}
@@ -0,0 +1,125 @@
import 'package:hash_wallet/entities/new_ui_entities/list_item/list_Item_checkbox.dart';
import 'package:hash_wallet/exchange/exchange_provider_description.dart';
import 'package:hash_wallet/generated/i18n.dart';
import 'package:hash_wallet/new-ui/widgets/coins_page/assets_history/history_swap_providers_page.dart';
import 'package:hash_wallet/new-ui/widgets/new_primary_button.dart';
import 'package:hash_wallet/new-ui/widgets/receive_page/receive_top_bar.dart';
import 'package:hash_wallet/new-ui/widgets/select_deselect_all.dart';
import 'package:hash_wallet/src/widgets/new_list_row/new_list_section.dart';
import 'package:hash_wallet/view_model/dashboard/dashboard_view_model.dart';
import 'package:hash_wallet/view_model/dashboard/filter_item.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:modal_bottom_sheet/modal_bottom_sheet.dart';
class HistoryFiltersPage extends StatelessWidget {
const HistoryFiltersPage({super.key, required this.dashboardViewModel});
final DashboardViewModel dashboardViewModel;
@override
Widget build(BuildContext context) {
return Material(
color: Colors.transparent,
child: Container(
color: Theme.of(context).colorScheme.surface,
child: Column(
children: [
ModalTopBar(
title: S.of(context).filters,
leadingIcon: Icon(Icons.arrow_back_ios_new),
leadingSemanticLabel: S.of(context).seed_alert_back,
onLeadingPressed: Navigator.of(context).pop,
),
Expanded(
child: SingleChildScrollView(
controller: ModalScrollController.of(context),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 18.0),
child: Observer(
builder: (_) => Column(
children: [
Padding(
padding: const EdgeInsets.only(bottom: 12.0),
child: Material(
color: Colors.transparent,
child: SelectDeselectAllBar(
title: S.of(context).type,
onSelected: dashboardViewModel.changeAllFilterItems),
),
),
NewListSections(
sections: {
"": dashboardViewModel.filterItems.map((item) {
if (item is SwapFilterItem) {
final String subtitle;
final Color subtitleColor;
if (dashboardViewModel.tradeFilterStore.displayAllTrades) {
subtitle = S.of(context).manage_providers;
subtitleColor = Theme.of(context).colorScheme.onSurfaceVariant;
} else if (dashboardViewModel
.tradeFilterStore.enabledProvidersCount ==
0) {
subtitle = S.of(context).no_providers_selected;
subtitleColor = Color(0xFFFFB84E);
} else {
subtitle = "${item.enabledProviders()} ${S.of(context).providers}";
subtitleColor = Theme.of(context).colorScheme.primary;
}
return ListItemCheckbox(
onTap: () {
Navigator.of(context).push(CupertinoPageRoute(
builder: (context) => HistorySwapProvidersPage(
dashboardViewModel: dashboardViewModel)));
},
keyValue: item.caption,
label: S.of(context).swap,
value: item.value(),
onChanged: (val) {
if ((val &&
dashboardViewModel
.tradeFilterStore.enabledProvidersCount ==
0) ||
(!val &&
dashboardViewModel
.tradeFilterStore.enabledProvidersCount >
0)) {
dashboardViewModel.tradeFilterStore
.toggleDisplayExchange(ExchangeProviderDescription.all);
}
},
subtitle: subtitle,
subtitleColor: subtitleColor,
showArrow: true);
}
return ListItemCheckbox(
keyValue: item.caption,
label: item.caption,
value: item.value(),
onChanged: (val) => item.onChanged());
}).toList()
},
),
],
),
),
),
)),
SafeArea(
child: Padding(
padding: const EdgeInsets.all(18.0),
child: NewPrimaryButton(
onPressed: () => dashboardViewModel.changeAllFilterItems(true),
text: S.of(context).reset_filters,
color: Theme.of(context).colorScheme.surfaceContainer,
textColor: Theme.of(context).colorScheme.primary),
))
],
),
),
);
}
}
@@ -0,0 +1,131 @@
import 'package:hash_wallet/core/csv_export_service.dart';
import 'package:hash_wallet/di.dart';
import 'package:hash_wallet/generated/i18n.dart';
import 'package:hash_wallet/new-ui/widgets/coins_page/assets_history/history_filters_page.dart';
import 'package:hash_wallet/new-ui/widgets/coins_page/assets_history/history_section.dart';
import 'package:hash_wallet/new-ui/widgets/receive_page/receive_top_bar.dart';
import 'package:hash_wallet/src/widgets/hash_image_widget.dart';
import 'package:hash_wallet/view_model/dashboard/dashboard_view_model.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:modal_bottom_sheet/modal_bottom_sheet.dart';
class HistoryModal extends StatelessWidget {
const HistoryModal({super.key, required this.dashboardViewModel});
final DashboardViewModel dashboardViewModel;
@override
Widget build(BuildContext context) {
return Container(
key: const ValueKey("history_modal_key"),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.vertical(top: Radius.circular(18))),
child: Column(
children: [
ModalTopBar(
title: S.of(context).history,
leadingIcon: Icon(Icons.close),
leadingSemanticLabel: S.of(context).close,
onLeadingPressed: Navigator.of(context).maybePop,
trailingIcon: HashImageWidget(
imageUrl: "assets/new-ui/tx_export.svg",
colorFilter: ColorFilter.mode(Theme.of(context).colorScheme.primary, BlendMode.srcIn),
),
trailingSemanticLabel: S.of(context).export_csv,
onTrailingPressed: () =>
getIt.get<CsvExportService>().exportToCsv(dashboardViewModel.items, context),
),
Expanded(
child: Stack(
children: [
Material(
child: CustomScrollView(physics: ClampingScrollPhysics(),
controller: ModalScrollController.of(context),
slivers: [
HistorySection(
detailsAsPage: true,
dashboardViewModel: dashboardViewModel,
short: false,
roundedTopSection: true)
])),
Positioned(
bottom: 0,
left: 0,
right: 0,
child: IgnorePointer(
child: Material(
color: Colors.transparent,
child: Container(
height: MediaQuery.of(context).viewPadding.bottom + 168,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.bottomCenter,
end: Alignment.topCenter,
colors: <Color>[
Theme.of(context).colorScheme.surface.withAlpha(200),
Theme.of(context).colorScheme.surface.withAlpha(175),
Theme.of(context).colorScheme.surface.withAlpha(150),
Theme.of(context).colorScheme.surface.withAlpha(100),
Theme.of(context).colorScheme.surface.withAlpha(50),
Theme.of(context).colorScheme.surface.withAlpha(25),
Theme.of(context).colorScheme.surface.withAlpha(5),
],
),
),
),
),
),
),
Positioned(
left: 12,
right: 12,
bottom: MediaQuery.of(context).viewPadding.bottom + 24,
child: Material(
borderRadius: BorderRadius.circular(18),
color: Theme.of(context).colorScheme.surfaceContainer,
child: InkWell(
borderRadius: BorderRadius.circular(18),
onTap: () {
Navigator.of(context).push(CupertinoPageRoute(
builder: (context) =>
HistoryFiltersPage(dashboardViewModel: dashboardViewModel)));
},
child: Material(
color: Colors.transparent,
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(18),
border: Border.all(
color: Theme.of(context).colorScheme.primary, width: 1)),
height: 56,
width: double.infinity,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
spacing: 10,
children: [
HashImageWidget(
imageUrl: "assets/new-ui/filter_options.svg",
colorFilter: ColorFilter.mode(
Theme.of(context).colorScheme.primary, BlendMode.srcIn),
),
Text(
S.of(context).filters,
style: TextStyle(
fontWeight: FontWeight.w500,
color: Theme.of(context).colorScheme.primary),
)
],
),
),
),
),
))
],
))
],
),
);
}
}
@@ -1,4 +1,5 @@
import 'package:hash_wallet/di.dart';
import 'package:hash_wallet/entities/balance_display_mode.dart';
import 'package:hash_wallet/generated/i18n.dart';
import 'package:hash_wallet/new-ui/widgets/coins_page/assets_history/anonpay_history_tile.dart';
import 'package:hash_wallet/new-ui/widgets/coins_page/assets_history/history_order_tile.dart';
@@ -7,7 +8,6 @@ import 'package:hash_wallet/new-ui/widgets/coins_page/assets_history/history_tra
import 'package:hash_wallet/new-ui/widgets/coins_page/assets_history/payjoin_history_tile.dart';
import 'package:hash_wallet/new-ui/widgets/coins_page/assets_history/transaction_details_modal.dart';
import 'package:hash_wallet/routes.dart';
import 'package:hash_wallet/utils/date_formatter.dart';
import 'package:hash_wallet/view_model/dashboard/anonpay_transaction_list_item.dart';
import 'package:hash_wallet/view_model/dashboard/dashboard_view_model.dart';
import 'package:hash_wallet/view_model/dashboard/date_section_item.dart';
@@ -15,176 +15,223 @@ import 'package:hash_wallet/view_model/dashboard/order_list_item.dart';
import 'package:hash_wallet/view_model/dashboard/payjoin_transaction_list_item.dart';
import 'package:hash_wallet/view_model/dashboard/trade_list_item.dart';
import 'package:hash_wallet/view_model/dashboard/transaction_list_item.dart';
import 'package:cw_core/amount/money.dart';
import 'package:cw_core/crypto_currency.dart';
import 'package:cw_core/sync_status.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:intl/intl.dart';
import "package:modal_bottom_sheet/modal_bottom_sheet.dart";
class HistorySection extends StatelessWidget {
const HistorySection({super.key, required this.dashboardViewModel});
const HistorySection(
{super.key,
required this.dashboardViewModel,
required this.short,
required this.roundedTopSection,
required this.detailsAsPage});
final DashboardViewModel dashboardViewModel;
final bool short;
final bool roundedTopSection;
final bool detailsAsPage;
/// A history row is a single button node: every text inside it (direction,
/// date, amounts) merges into one label.
Widget _historyRow({required VoidCallback onTap, required Widget child}) => MergeSemantics(
child: Semantics(
button: true,
child: GestureDetector(onTap: onTap, child: child),
),
);
@override
Widget build(BuildContext context) {
return SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
padding: EdgeInsets.only(left: 16.0, right: 16, top: short && roundedTopSection ? 18 : 0),
sliver: Observer(
builder: (_) => (dashboardViewModel.items.isEmpty)
? SliverPadding(
padding: EdgeInsets.only(top: 24),
sliver: SliverToBoxAdapter(
child: (dashboardViewModel.status is SyncingSyncStatus)
? SizedBox.shrink()
: Center(
child: Text(S.of(context).transactions_will_appear_here,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w400,
color: Theme.of(context).colorScheme.onSurfaceVariant)),
),
),
)
: SliverList(
delegate: SliverChildBuilderDelegate(
childCount: dashboardViewModel.items.length,
(context, index) => Observer(builder: (_) {
final prevItem = index == 0 ? null : dashboardViewModel.items[index - 1];
final topPadding = index == 0 ? 0.0 : 18.0;
final item = dashboardViewModel.items[index];
final nextItem = index == dashboardViewModel.items.length - 1
? null
: dashboardViewModel.items[index + 1];
builder: (_) {
final localeName = Localizations.localeOf(context).toString();
final items = short ? dashboardViewModel.itemsShort : dashboardViewModel.items;
final roundedBottom = (nextItem == null || nextItem is DateSectionItem);
final roundedTop = (prevItem == null || prevItem is DateSectionItem);
return (items.isEmpty)
? SliverPadding(
padding: EdgeInsets.only(top: 24),
sliver: SliverToBoxAdapter(
child: (dashboardViewModel.status is SyncingSyncStatus)
? SizedBox.shrink()
: Center(
child: Text(S.of(context).transactions_will_appear_here,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w400,
color: Theme.of(context).colorScheme.onSurfaceVariant)),
),
),
)
: SliverPadding(
padding: EdgeInsets.only(bottom: short ? 0 : 144),
sliver: SliverList(
delegate: SliverChildBuilderDelegate(
childCount: items.length,
(context, index) => Observer(builder: (_) {
final prevItem = index == 0 ? null : items[index - 1];
final topPadding = index == 0 ? 0.0 : 18.0;
final item = items[index];
final nextItem = index == items.length - 1 ? null : items[index + 1];
if (item is TransactionListItem) {
final transaction = item.transaction;
final transactionType = dashboardViewModel.getTransactionType(transaction);
final roundedBottom = (nextItem == null || nextItem is DateSectionItem);
final roundedTop = roundedTopSection &&
(prevItem == null || prevItem is DateSectionItem);
if (item.hasTokens && item.assetOfTransaction == null) {
return Container();
}
if (item is TransactionListItem) {
final transaction = item.transaction;
final transactionType =
dashboardViewModel.getTransactionType(transaction);
CryptoCurrency? asset;
if (transaction.additionalInfo["isLightning"] == true)
asset = CryptoCurrency.btcln;
else
asset = item.assetOfTransaction;
if (item.hasTokens && item.assetOfTransaction == null) {
return Container();
}
return GestureDetector(
onTap: () {
final page = getIt.get<TransactionDetailsModal>(param1: transaction);
showModalBottomSheet(
isScrollControlled: true,
context: context,
builder: (context) => page);
},
child: HistoryTile(
title: item.formattedTitle + transactionType,
date: DateFormat('HH:mm').format(transaction.date),
amount: item.formattedCryptoAmount,
amountFiat: item.formattedFiatAmount,
hasTokens: item.hasTokens,
chainIconPath: _getChainIconPath(),
roundedBottom: roundedBottom,
roundedTop: roundedTop,
bottomSeparator: !roundedBottom,
direction: item.transaction.direction,
pending: item.transaction.isPending,
asset: asset,
),
);
} else if (item is TradeListItem) {
final trade = item.trade;
final tradeFrom = trade.from;
final tradeTo = trade.to;
if (tradeFrom == null || tradeTo == null) {
return const SizedBox.shrink();
}
CryptoCurrency? asset;
if (transaction.additionalInfo["isLightning"] == true)
asset = CryptoCurrency.btcln;
else
asset = item.assetOfTransaction;
return GestureDetector(
onTap: () => Navigator.of(context)
.pushNamed(Routes.tradeDetails, arguments: trade),
child: HistoryTradeTile(
from: tradeFrom,
to: tradeTo,
provider: trade.provider,
date:
DateFormat('HH:mm').format(item.trade.createdAt ?? DateTime.now()),
amount: trade.amountFormatted(),
receiveAmount: trade.receiveAmountFormatted(),
roundedBottom: roundedBottom,
roundedTop: roundedTop,
bottomSeparator: !roundedBottom,
swapState: trade.state,
),
);
} else if (item is DateSectionItem) {
return Padding(
padding: EdgeInsets.only(left: 8.0, bottom: 8.0, top: topPadding),
child: Text(DateFormatter.convertDateTimeToReadableString(item.date),
style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant)));
} else if (item is OrderListItem) {
return GestureDetector(
onTap: () => Navigator.of(context)
.pushNamed(Routes.orderDetails, arguments: item.order),
child: HistoryOrderTile(
date: DateFormat('HH:mm').format(item.order.createdAt),
amount: item.orderFormattedAmount,
amountFiat: "USD 0.00",
roundedBottom: roundedBottom,
roundedTop: roundedTop,
bottomSeparator: !roundedBottom,
),
);
} else if (item is PayjoinTransactionListItem) {
final session = item.session;
return GestureDetector(
onTap: () => Navigator.of(context).pushNamed(
Routes.payjoinDetails,
arguments: [item.sessionId, item.transaction],
),
child: PayjoinHistoryTile(
createdAt: DateFormat('HH:mm').format(session.inProgressSince!),
amount: dashboardViewModel.appStore.amountParsingProxy
.getDisplayCryptoString(
session.amount.toInt(), CryptoCurrency.btc),
currency: item.transaction?.from ?? "BTC",
state: item.status,
isSending: session.isSenderSession,
roundedTop: roundedTop,
roundedBottom: roundedBottom,
bottomSeparator: !roundedBottom),
);
} else if (item is AnonpayTransactionListItem) {
final transactionInfo = item.transaction;
return GestureDetector(
onTap: () => Navigator.of(context)
.pushNamed(Routes.anonPayDetailsPage, arguments: transactionInfo),
child: AnonpayHistoryTile(
provider: transactionInfo.provider,
createdAt: DateFormat('HH:mm').format(transactionInfo.createdAt),
amount: transactionInfo.fiatAmount?.toString() ??
(transactionInfo.amountTo?.toString() ?? ''),
currency: transactionInfo.fiatAmount != null
? transactionInfo.fiatEquiv ?? ''
: CryptoCurrency.fromFullName(transactionInfo.coinTo)
.name
.toUpperCase(),
roundedTop: roundedTop,
return _historyRow(
onTap: () {
final page =
getIt.get<TransactionDetailsModal>(param1: transaction);
if (detailsAsPage) {
Navigator.of(context).push(CupertinoPageRoute(
builder: (context) => Material(child: page)));
} else {
showMaterialModalBottomSheet(
backgroundColor: Colors.transparent,
context: context,
builder: (context) =>
FractionallySizedBox(heightFactor: 0.9, child: page));
}
},
child: HistoryTile(
key: ValueKey("home_page_transaction_${transaction.id}_key"),
title: item.formattedTitle + transactionType,
date: _formatTransactionDate(item.date, localeName),
amount: item.formattedCryptoAmount,
amountFiat: item.formattedFiatAmount,
hasTokens: item.hasTokens,
chainIconPath: _getChainIconPath(),
roundedBottom: roundedBottom,
bottomSeparator: !roundedBottom));
} else
return Text(item.runtimeType.toString());
}),
),
),
roundedTop: roundedTop,
bottomSeparator: !roundedBottom,
direction: item.transaction.direction,
pending: item.transaction.isPending,
asset: asset,
),
);
} else if (item is TradeListItem) {
final trade = item.trade;
final tradeFrom = trade.from;
final tradeTo = trade.to;
return _historyRow(
onTap: () => Navigator.of(context)
.pushNamed(Routes.tradeDetails, arguments: trade),
child: HistoryTradeTile(
from: tradeFrom,
to: tradeTo,
provider: trade.provider,
date: _formatTransactionDate(
item.trade.createdAt ?? DateTime.now(), localeName),
amount: dashboardViewModel.balanceDisplayMode ==
BalanceDisplayMode.hiddenBalance
? "---"
: trade.amountFormatted(),
receiveAmount: dashboardViewModel.balanceDisplayMode ==
BalanceDisplayMode.hiddenBalance
? "---"
: trade.receiveAmountFormatted(),
roundedBottom: roundedBottom,
roundedTop: roundedTop,
bottomSeparator: !roundedBottom,
swapState: trade.state,
),
);
} else if (item is SpecificDateSectionItem) {
return Padding(
padding: EdgeInsets.only(left: 8.0, bottom: 8.0, top: topPadding),
child: Text(item.text,
style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant)));
} else if (item is DateSectionItem) {
return Padding(
padding: EdgeInsets.only(left: 8.0, bottom: 8.0, top: topPadding),
child: Text(DateFormat("MMMM yyyy", localeName).format(item.date),
style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant)));
} else if (item is OrderListItem) {
return _historyRow(
onTap: () => Navigator.of(context)
.pushNamed(Routes.orderDetails, arguments: item.order),
child: HistoryOrderTile(
date: _formatTransactionDate(item.order.createdAt, localeName),
amount: item.orderFormattedAmount,
amountFiat: "",
roundedBottom: roundedBottom,
roundedTop: roundedTop,
bottomSeparator: !roundedBottom,
),
);
} else if (item is PayjoinTransactionListItem) {
final session = item.session;
return _historyRow(
onTap: () => Navigator.of(context).pushNamed(
Routes.payjoinDetails,
arguments: [item.sessionId, item.transaction],
),
child: PayjoinHistoryTile(
createdAt:
_formatTransactionDate(session.inProgressSince!, localeName),
amount: dashboardViewModel.appStore.amountParsingProxy
.asDisplayString(Money(session.amount, CryptoCurrency.btc)),
currency: item.transaction?.from ?? "BTC",
state: item.status,
isSending: session.isSenderSession,
roundedTop: roundedTop,
roundedBottom: roundedBottom,
bottomSeparator: !roundedBottom),
);
} else if (item is AnonpayTransactionListItem) {
final transactionInfo = item.transaction;
return _historyRow(
onTap: () => Navigator.of(context).pushNamed(
Routes.anonPayDetailsPage,
arguments: transactionInfo),
child: AnonpayHistoryTile(
provider: transactionInfo.provider,
createdAt: _formatTransactionDate(
transactionInfo.createdAt, localeName),
amount: transactionInfo.fiatAmount?.toString() ??
(transactionInfo.amountTo?.toString() ?? ''),
currency: transactionInfo.fiatAmount != null
? transactionInfo.fiatEquiv ?? ''
: CryptoCurrency.fromFullName(transactionInfo.coinTo)
.name
.toUpperCase(),
roundedTop: roundedTop,
roundedBottom: roundedBottom,
bottomSeparator: !roundedBottom));
} else
return Text(item.runtimeType.toString());
}),
),
),
);
},
));
}
@@ -197,4 +244,36 @@ class HistorySection extends StatelessWidget {
return dashboardViewModel.wallet.currency.chainIconPath ?? "";
}
}
String _formatTransactionDate(DateTime date, String localeName) {
final time = DateFormat.Hm(localeName);
final now = DateTime.now();
final today = DateTime(now.year, now.month, now.day);
final thatDay = DateTime(date.year, date.month, date.day);
final daysAgo = today.difference(thatDay).inDays;
final timeStr = time.format(date);
if (daysAgo == 0) {
return timeStr;
}
if (daysAgo == 1) {
return "${S.current.yesterday}, $timeStr";
}
if (daysAgo < 7) {
final weekday = DateFormat.EEEE(localeName).format(date);
return "$weekday, $timeStr";
}
if (date.year == now.year) {
final dayMonth = DateFormat("d MMMM", localeName).format(date);
return "$dayMonth, $timeStr";
}
final full = DateFormat("d MMM yyyy", localeName).format(date);
return "$full, $timeStr";
}
}
@@ -0,0 +1,78 @@
import 'package:hash_wallet/entities/new_ui_entities/list_item/list_Item_checkbox.dart';
import 'package:hash_wallet/exchange/exchange_provider_description.dart';
import 'package:hash_wallet/generated/i18n.dart';
import 'package:hash_wallet/new-ui/widgets/receive_page/receive_top_bar.dart';
import 'package:hash_wallet/new-ui/widgets/select_deselect_all.dart';
import 'package:hash_wallet/src/widgets/new_list_row/new_list_section.dart';
import 'package:hash_wallet/view_model/dashboard/dashboard_view_model.dart';
import 'package:hash_wallet/view_model/dashboard/filter_item.dart';
import 'package:flutter/material.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:modal_bottom_sheet/modal_bottom_sheet.dart';
class HistorySwapProvidersPage extends StatelessWidget {
const HistorySwapProvidersPage({super.key, required this.dashboardViewModel});
final DashboardViewModel dashboardViewModel;
@override
Widget build(BuildContext context) {
return Container(
color: Theme.of(context).colorScheme.surface,
child: Column(
children: [
ModalTopBar(
title: S.of(context).filter_swap_providers,
leadingIcon: Icon(Icons.arrow_back_ios_new),
leadingSemanticLabel: S.of(context).seed_alert_back,
onLeadingPressed: Navigator.of(context).pop,
),
Expanded(
child: SingleChildScrollView(
controller: ModalScrollController.of(context),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 18.0),
child: Observer(
builder: (_) => Column(
children: [
Padding(
padding: const EdgeInsets.only(bottom: 12.0),
child: Material(
color: Colors.transparent,
child: SelectDeselectAllBar(
title: S.of(context).swap_providers,
onSelected: (val) {
if ((val &&
dashboardViewModel.tradeFilterStore.enabledProvidersCount ==
0) ||
(!val &&
dashboardViewModel.tradeFilterStore.enabledProvidersCount >
0)) {
dashboardViewModel.tradeFilterStore
.toggleDisplayExchange(ExchangeProviderDescription.all);
}
}),
),
),
NewListSections(sections: {
"": dashboardViewModel.exchangeFilterItems
.whereType<SwapProviderFilterItem>()
.map((item) => ListItemCheckbox(
iconPath: item.providerDescription.image,
keyValue: item.caption,
label: item.caption,
value: item.value(),
onChanged: (val) => dashboardViewModel.tradeFilterStore
.toggleDisplayExchange(item.providerDescription)))
.toList()
}),
],
),
),
),
))
],
),
);
}
}
@@ -1,4 +1,5 @@
import 'package:hash_wallet/new-ui/widgets/coins_page/assets_history/history_tile_base.dart';
import 'package:hash_wallet/new-ui/widgets/coins_page/token_image_widget.dart';
import 'package:hash_wallet/src/widgets/hash_image_widget.dart';
import 'package:cw_core/crypto_currency.dart';
import 'package:cw_core/transaction_direction.dart';
@@ -80,13 +81,13 @@ class HistoryTile extends StatelessWidget {
color: Theme.of(context).colorScheme.onInverseSurface, shape: BoxShape.circle),
child: HashImageWidget(
imageUrl: _getDirectionIconToken(),
height: 14,
width: 14,
colorFilter: ColorFilter.mode(
direction == TransactionDirection.outgoing
? Theme.of(context).colorScheme.inverseSurface.withAlpha(175)
: Colors.green,
BlendMode.srcIn),
width: 14,
height: 14,
),
),
)
@@ -99,10 +100,9 @@ class HistoryTile extends StatelessWidget {
children: [
Opacity(
opacity: pending ? 0.5 : 1,
child: HashImageWidget(
child: TokenImageWidget(
imageUrl: asset?.iconPath ?? "",
width: 34,
height: 34,
size: 34,
),
),
Align(
@@ -142,7 +142,8 @@ class HistoryTile extends StatelessWidget {
date: date,
amount: amount,
amountFiat: amountFiat,
leadingIcon: _getLeadingIcon(context),
// Decorative: `title` already reads out sent/received/pending.
leadingIcon: ExcludeSemantics(child: _getLeadingIcon(context)),
primaryTextColor: _getPrimaryTextColor(),
roundedTop: roundedTop,
roundedBottom: roundedBottom,
@@ -87,7 +87,7 @@ class HistoryTileBase extends StatelessWidget {
bottomLeft: Radius.circular(roundedBottom ? 22 : 0),
bottomRight: Radius.circular(roundedBottom ? 22 : 0),
)),
color: Theme.of(context).colorScheme.onInverseSurface,
color: Theme.of(context).colorScheme.surfaceContainer,
),
child: Padding(
padding: const EdgeInsets.symmetric(
@@ -153,7 +153,7 @@ class HistoryTileBase extends StatelessWidget {
),
if (bottomSeparator)
Container(
color: Theme.of(context).colorScheme.onInverseSurface,
color: Theme.of(context).colorScheme.surfaceContainer,
child: Padding(
padding: EdgeInsets.only(left: 56, right: 16),
child: Container(
@@ -0,0 +1,69 @@
import 'package:hash_wallet/generated/i18n.dart';
import 'package:hash_wallet/src/widgets/hash_image_widget.dart';
import 'package:flutter/material.dart';
class HistoryTopBar extends StatelessWidget {
const HistoryTopBar({super.key, required this.onTap, required this.roundedBottom});
final VoidCallback onTap;
final bool roundedBottom;
@override
Widget build(BuildContext context) {
return SliverToBoxAdapter(
child: Semantics(
button: true,
label: S.of(context).history,
onTap: onTap,
child: ExcludeSemantics(
child: GestureDetector(
key: const ValueKey("history_top_bar_key"),
onTap: onTap,
behavior: HitTestBehavior.opaque,
child: Padding(
padding: const EdgeInsets.only(left: 16, right: 16, top: 20),
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.vertical(
top: Radius.circular(18),
bottom: roundedBottom ? Radius.circular(18) : Radius.zero),
color: Theme.of(context).colorScheme.surfaceContainer),
child: Padding(
padding: EdgeInsets.symmetric(
vertical: 4,
horizontal: 12,
),
child: Column(
spacing: 12,
children: [
SizedBox.shrink(),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(S.of(context).history),
HashImageWidget(
imageUrl: "assets/new-ui/arrow_right.svg",
colorFilter: ColorFilter.mode(
Theme.of(context).colorScheme.onSurfaceVariant, BlendMode.srcIn),
)
],
),
if (!roundedBottom)
Container(
height: 1,
width: double.infinity,
color: Theme.of(context).colorScheme.outlineVariant.withAlpha(175),
)
else
Container(height: 2)
],
),
),
),
),
),
),
),
);
}
}
@@ -15,13 +15,13 @@ class HistoryTradeTile extends StatelessWidget {
required this.roundedTop,
required this.roundedBottom,
required this.bottomSeparator,
required this.from,
required this.to,
this.from,
this.to,
required this.swapState,
required this.provider});
final CryptoCurrency from;
final CryptoCurrency to;
final CryptoCurrency? from;
final CryptoCurrency? to;
final ExchangeProviderDescription provider;
final String date;
final String amount;
@@ -32,6 +32,10 @@ class HistoryTradeTile extends StatelessWidget {
final TradeState swapState;
Widget _getLeadingStack(BuildContext context) {
if (from == null || to == null) {
return SizedBox(width: 50, height: 50);
}
double currencyIconSize = 22.0;
return SizedBox(
@@ -40,7 +44,7 @@ class HistoryTradeTile extends StatelessWidget {
child: Stack(
children: [
HashImageWidget(
imageUrl: _getIconPath(from), width: currencyIconSize, height: currencyIconSize),
imageUrl: _getIconPath(from!), width: currencyIconSize, height: currencyIconSize),
Positioned(
top: currencyIconSize / 2,
left: currencyIconSize / 2,
@@ -50,7 +54,7 @@ class HistoryTradeTile extends StatelessWidget {
Border.all(width: 2, color: Theme.of(context).colorScheme.surfaceContainer),
shape: BoxShape.circle),
child: HashImageWidget(
imageUrl: _getIconPath(to),
imageUrl: _getIconPath(to!),
width: currencyIconSize,
height: currencyIconSize,
),
@@ -85,8 +89,10 @@ class HistoryTradeTile extends StatelessWidget {
style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w500)),
Text(from.title, style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant)),
if (fromChainIcon.isNotEmpty)
if (from?.title != null)
Text(from!.title,
style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant)),
if (fromChainIcon?.isNotEmpty ?? false)
HashImageWidget(
imageUrl: fromChainIcon,
width: 12,
@@ -106,10 +112,17 @@ class HistoryTradeTile extends StatelessWidget {
receiveAmount.withMaxDecimals(8),
style: TextStyle(fontWeight: FontWeight.w500),
),
Text(
to.title,
),
if (toChainIcon.isNotEmpty) HashImageWidget(imageUrl: toChainIcon, width: 12, height: 12)
if (to != null)
Text(
to!.title,
),
if (toChainIcon?.isNotEmpty ?? false)
HashImageWidget(
imageUrl: toChainIcon,
width: 12,
height: 12,
color: Theme.of(context).colorScheme.onSurface,
)
],
),
leadingIcon: _getLeadingStack(context),
@@ -137,7 +150,8 @@ class HistoryTradeTile extends StatelessWidget {
return "";
}
String _getChainIcon(CryptoCurrency currency) {
String? _getChainIcon(CryptoCurrency? currency) {
if (currency == null) return null;
try {
if (currency.title.isNotEmpty) {
final parsedCurrency =
@@ -1,23 +1,31 @@
import 'package:hash_wallet/entities/new_ui_entities/list_item/list_item.dart';
import 'package:hash_wallet/entities/new_ui_entities/list_item/list_item_regular_row.dart';
import 'package:hash_wallet/generated/i18n.dart';
import 'package:hash_wallet/new-ui/widgets/receive_page/receive_top_bar.dart';
import 'package:hash_wallet/routes.dart';
import 'package:hash_wallet/src/screens/transaction_details/confirmations_list_item.dart';
import 'package:hash_wallet/src/screens/transaction_details/transaction_details_list_item.dart';
import 'package:hash_wallet/src/screens/transaction_details/address_list_item.dart';
import 'package:hash_wallet/src/widgets/hash_image_widget.dart';
import 'package:hash_wallet/src/widgets/new_list_row/new_list_section.dart';
import 'package:hash_wallet/utils/address_formatter.dart';
import 'package:hash_wallet/view_model/transaction_details_view_model.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import "package:hash_wallet/entities/new_ui_entities/list_item/list_item.dart";
import "package:hash_wallet/entities/new_ui_entities/list_item/list_item_regular_row.dart";
import "package:hash_wallet/generated/i18n.dart";
import "package:hash_wallet/new-ui/widgets/coins_page/token_image_widget.dart";
import "package:hash_wallet/new-ui/widgets/copy_wrapper.dart";
import "package:hash_wallet/new-ui/widgets/receive_page/receive_top_bar.dart";
import "package:hash_wallet/routes.dart";
import "package:hash_wallet/src/screens/transaction_details/address_list_item.dart";
import "package:hash_wallet/src/screens/transaction_details/confirmations_list_item.dart";
import "package:hash_wallet/src/screens/transaction_details/transaction_details_list_item.dart";
import "package:hash_wallet/src/widgets/new_list_row/new_list_section.dart";
import "package:hash_wallet/utils/address_formatter.dart";
import "package:hash_wallet/view_model/transaction_details_view_model.dart";
import "package:flutter/material.dart";
import "package:flutter/services.dart";
import "package:flutter_mobx/flutter_mobx.dart";
import "package:modal_bottom_sheet/modal_bottom_sheet.dart";
class TransactionDetailsModal extends StatefulWidget {
const TransactionDetailsModal({super.key, required this.transactionDetailsViewModel});
const TransactionDetailsModal(
{
required this.transactionDetailsViewModel,
this.highlightNoteField = false,
super.key,
});
final TransactionDetailsViewModel transactionDetailsViewModel;
final bool highlightNoteField;
@override
State<TransactionDetailsModal> createState() => _TransactionDetailsModalState();
@@ -37,191 +45,237 @@ class _TransactionDetailsModalState extends State<TransactionDetailsModal> {
widget.transactionDetailsViewModel.updateNote(noteController.text);
}
});
if (widget.highlightNoteField) {
noteFocusNode.requestFocus();
}
}
@override
Widget build(BuildContext context) {
return DraggableScrollableSheet(
expand: false,
initialChildSize: 0.9,
minChildSize: 0.25,
maxChildSize: 0.9,
snap: true,
snapSizes: const [0.9],
builder: (context, controller) => SafeArea(
bottom: false,
child: Padding(
padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
child: GestureDetector(
onTap: FocusScope.of(context).unfocus,
child: Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.vertical(top: Radius.circular(25))),
child: Column(
children: [
ModalTopBar(
title: S.of(context).transaction,
leadingIcon: Icon(Icons.close),
onLeadingPressed: Navigator.of(context).pop,
),
Expanded(
child: SingleChildScrollView(
controller: controller,
child: Column(
children: [
HashImageWidget(
imageUrl: widget.transactionDetailsViewModel.transactionAsset
.iconPath ??
"",
width: 64,
height: 64),
SizedBox(height: 10),
Text(
widget.transactionDetailsViewModel.formattedTitle +
widget.transactionDetailsViewModel.formattedStatus,
style: TextStyle(fontSize: 20, fontWeight: FontWeight.w500),
),
Text(
widget.transactionDetailsViewModel.formattedCryptoAmount,
style: TextStyle(fontSize: 28),
),
Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
spacing: 12,
children: [
NewListSections(sections: {
"": widget.transactionDetailsViewModel.items
.map((item) {
if (item.value.isEmpty) return null;
final shouldBuildBottomWidget =
item.value.length > 25;
return ListItemRegularRow(
copyableText: item.value,
showArrow: false,
keyValue:
((item.key as ValueKey?)?.value as String?) ??
item.title,
label: item.title,
trailingWidget: shouldBuildBottomWidget
? null
: _buildTrailingWIdget(item),
bottomWidget: shouldBuildBottomWidget
? _buildBottomWidget(item)
: null);
})
.whereType<ListItem>()
.toList(),
}),
Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
color: Theme.of(context).colorScheme.surfaceContainer),
child: Padding(
padding: const EdgeInsets.all(12.0),
child: Column(
spacing: 8,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(S.of(context).note),
TextField(
focusNode: noteFocusNode,
controller: noteController,
decoration: InputDecoration(
hintText: S.of(context).add_a_note,
border: InputBorder.none,
focusedBorder: InputBorder.none,
enabledBorder: InputBorder.none,
contentPadding: EdgeInsets.zero,
isDense: true),
)
],
),
),
),
Observer(
builder: (_) => NewListSections(sections: {
"view tx": [
ListItemRegularRow(
keyValue: "view tx on",
label: widget.transactionDetailsViewModel
.explorerDescription,
onTap: widget
.transactionDetailsViewModel.launchExplorer,
foregroundColor:
Theme.of(context).colorScheme.primary,
trailingIconPath: "assets/new-ui/link_arrow.svg",
trailingIconSize: 8)
],
if (widget.transactionDetailsViewModel.canReplaceByFee)
"rbf": [
ListItemRegularRow(
keyValue: "replace by fee",
label: S.of(context).bump_fee,
onTap: () {
Navigator.of(context)
.pushNamed(Routes.bumpFeePage, arguments: [
widget.transactionDetailsViewModel
.transactionInfo,
widget.transactionDetailsViewModel
.rawTransaction
]);
})
]
}),
)
],
Widget build(BuildContext context) => SafeArea(
bottom: false,
child: Padding(
padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
child: GestureDetector(
onTap: FocusScope.of(context).unfocus,
child: Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: const BorderRadius.vertical(top: Radius.circular(25)),
),
child: Column(
children: [
ModalTopBar(
title: S.of(context).transaction,
leadingIcon: const Icon(Icons.close),
leadingSemanticLabel: S.of(context).close,
onLeadingPressed: Navigator.of(context).pop,
),
Expanded(
child: SingleChildScrollView(
controller: ModalScrollController.of(context),
child: Column(
children: [
TokenImageWidget(
imageUrl:
widget.transactionDetailsViewModel.transactionAsset.iconPath ?? "",
size: 64,
),
const SizedBox(height: 10),
Text(
widget.transactionDetailsViewModel.formattedTitle +
widget.transactionDetailsViewModel.formattedStatus,
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w500),
),
Observer(
builder: (_) => CopyWrapper(
requireLongPress: true,
data: ClipboardData(
text: widget.transactionDetailsViewModel.transactionCopyAmount,
),
builder: (context, copied) => AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
child: Text(
key: ValueKey(copied),
copied
? S.of(context).copied
: widget.transactionDetailsViewModel.transactionAmount,
style: TextStyle(
fontSize: 28,
color: copied
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.onSurface,
),
),
),
),
),
Padding(
padding: const EdgeInsets.all(16),
child: Column(
spacing: 12,
children: [
NewListSections(
sections: {
"": widget.transactionDetailsViewModel.items
.map((item) {
if (item.value.isEmpty) {
return null;
}
final shouldBuildBottomWidget = item.value.length > 25;
return ListItemRegularRow(
copyableText: item.value,
showArrow: false,
keyValue: ((item.key as ValueKey?)?.value as String?) ??
item.title,
label: item.title,
trailingWidget: shouldBuildBottomWidget
? null
: _buildTrailingWidget(item),
bottomWidget: shouldBuildBottomWidget
? _buildBottomWidget(item)
: null,
);
})
.whereType<ListItem>()
.toList(),
},
),
Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
color: Theme.of(context).colorScheme.surfaceContainer,
),
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
spacing: 8,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(S.of(context).note),
TextField(
focusNode: noteFocusNode,
controller: noteController,
decoration: InputDecoration(
hintText: S.of(context).add_a_note,
border: InputBorder.none,
focusedBorder: InputBorder.none,
enabledBorder: InputBorder.none,
contentPadding: EdgeInsets.zero,
isDense: true,
),
),
],
),
),
),
Observer(
builder: (_) => NewListSections(
sections: {
"view tx": [
ListItemRegularRow(
keyValue: "view tx on",
label: widget
.transactionDetailsViewModel.explorerDescription,
onTap: widget.transactionDetailsViewModel.launchExplorer,
foregroundColor: Theme.of(context).colorScheme.primary,
trailingIconPath: "assets/new-ui/link_arrow.svg",
trailingIconSize: 8,
),
],
if (widget.transactionDetailsViewModel.canReplaceByFee)
"rbf": [
ListItemRegularRow(
keyValue: "replace by fee",
label: S.of(context).bump_fee,
onTap: () {
Navigator.of(context).pushNamed(
Routes.bumpFeePage,
arguments: [
widget
.transactionDetailsViewModel.transactionInfo,
widget.transactionDetailsViewModel.rawTransaction,
],
);
},
),
],
},
),
),
SizedBox(height: MediaQuery.of(context).viewPadding.bottom)
],
),
),
)
],
SizedBox(height: MediaQuery.of(context).viewPadding.bottom),
],
),
),
),
),
],
),
));
}
Widget _buildTrailingWIdget(TransactionDetailsListItem item) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4.0),
child: switch (item.runtimeType) {
ConfirmationsListItem => Row(
children: [
Text((item as ConfirmationsListItem).current.toString(),
style: TextStyle(color: Theme.of(context).colorScheme.primary)),
if (item.needed > 0)
Text("/${item.needed}",
style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant))
],
),
),
_ => Text(
item.value,
style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant),
)
},
);
}
),
);
Widget _buildTrailingWidget(TransactionDetailsListItem item) => Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: switch (item.runtimeType) {
ConfirmationsListItem => Row(
children: [
Text(
(item as ConfirmationsListItem).current.toString(),
style: TextStyle(color: Theme.of(context).colorScheme.primary),
),
if (item.needed > 0)
Text(
"/${item.needed}",
style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant),
),
],
),
_ => Text(
item.value,
style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant),
)
},
);
Widget _buildBottomWidget(TransactionDetailsListItem item) {
return switch (item.runtimeType) {
AddressListItem => AddressFormatter.buildSegmentedAddress(
address: item.value,
evenTextStyle: TextStyle(
fontSize: 12,
fontFamily: "IBM Plex Mono",
color: Theme.of(context).colorScheme.onSurface)),
AddressListItem => _segmentedAddressList(item.value),
_ => Text(
item.value,
style: TextStyle(fontSize: 12, color: Theme.of(context).colorScheme.onSurfaceVariant),
)
};
}
Widget _segmentedAddressList(String value) {
final style = TextStyle(
fontSize: 12,
fontFamily: "IBM Plex Mono",
color: Theme.of(context).colorScheme.onSurface,
);
final lines = value
.split(RegExp(r'[,\n]'))
.map((final line) => line.trim())
.where((final line) => line.isNotEmpty)
.toList();
if (lines.length <= 1) {
return AddressFormatter.buildSegmentedAddress(
address: lines.isNotEmpty ? lines.first : value.trim(),
evenTextStyle: style,
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (final line in lines)
AddressFormatter.buildSegmentedAddress(address: line, evenTextStyle: style),
],
);
}
}
@@ -1,8 +1,9 @@
import 'package:hash_wallet/generated/i18n.dart';
import 'package:hash_wallet/src/widgets/hash_image_widget.dart';
import 'package:cw_core/card_design.dart';
import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
import "package:hash_wallet/generated/i18n.dart";
import "package:hash_wallet/new-ui/widgets/money/money_text.dart";
import "package:hash_wallet/src/widgets/hash_image_widget.dart";
import "package:cw_core/amount/money.dart";
import "package:cw_core/card_design.dart";
import "package:flutter/material.dart";
class BalanceCardAction {
final String label;
@@ -23,7 +24,7 @@ class BalanceCard extends StatelessWidget {
this.borderRadius = 20,
this.selected = false,
this.accountName = "",
this.accountBalance = "",
this.accountBalance,
this.balance = "",
this.fiatBalance = "",
this.assetName = "",
@@ -38,7 +39,7 @@ class BalanceCard extends StatelessWidget {
final double width;
final double borderRadius;
final Gradient? gradient;
final String accountBalance;
final Money? accountBalance;
final String accountName;
final String balance;
final String fiatBalance;
@@ -63,7 +64,7 @@ class BalanceCard extends StatelessWidget {
final leadText = fiatFirst ? S.of(context).wallet_balance : accountName;
final bool showText = accountBalance.isNotEmpty ||
final bool showText = accountBalance != null ||
leadText.isNotEmpty ||
balance.isNotEmpty ||
fiatBalance.isNotEmpty ||
@@ -92,15 +93,18 @@ class BalanceCard extends StatelessWidget {
? ClipRSuperellipse(
borderRadius: BorderRadius.circular(borderRadius),
key: ValueKey(design.imagePath),
child: HashImageWidget(
imageUrl: design.imagePath,
width: width,
height: height,
fit: BoxFit.fill,
// Purely decorative card artwork.
child: ExcludeSemantics(
child: HashImageWidget(
imageUrl: design.imagePath,
width: width,
height: height,
fit: BoxFit.fill,
),
),
)
: const SizedBox.shrink(
key: ValueKey('svgFullOff'),
key: ValueKey("svgFullOff"),
),
),
Padding(
@@ -115,7 +119,7 @@ class BalanceCard extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.max,
children: [
if (leadText.isNotEmpty || accountBalance.isNotEmpty)
if (leadText.isNotEmpty || accountBalance != null)
Row(
mainAxisSize: MainAxisSize.max,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
@@ -147,9 +151,16 @@ class BalanceCard extends StatelessWidget {
AnimatedOpacity(
opacity: selected ? 0 : 1,
duration: textFadeDuration,
child: Text(
accountBalance,
style: TextStyle(color: design.colors.textColor, fontSize: 14),
// Opacity alone keeps the text readable by screen
// readers, so drop it while it is invisible.
child: ExcludeSemantics(
excluding: selected,
child: MoneyText.optional(
accountBalance,
fractionalDigits: accountBalance?.decimals ?? 8,
showSymbol: false,
style: TextStyle(color: design.colors.textColor, fontSize: 14),
),
),
),
],
@@ -157,40 +168,51 @@ class BalanceCard extends StatelessWidget {
AnimatedOpacity(
opacity: selected ? 1 : 0,
duration: textFadeDuration,
child: AnimatedSwitcher(
duration: designSwitchDuration,
layoutBuilder: (currentChild, previousChildren) {
return Stack(
alignment: Alignment.centerLeft,
children: <Widget>[
...previousChildren,
if (currentChild != null) currentChild,
// Only the selected card's balance is visible, so only it
// may be announced.
child: ExcludeSemantics(
excluding: !selected,
child: AnimatedSwitcher(
duration: designSwitchDuration,
layoutBuilder: (currentChild, previousChildren) {
return Stack(
alignment: Alignment.centerLeft,
children: <Widget>[
...previousChildren,
if (currentChild != null) currentChild,
],
);
},
child: Row(
key: ValueKey("$balance ${resolvedAssetName.toUpperCase()}"),
spacing: 8.0,
children: [
AnimatedDefaultTextStyle(
duration: designSwitchDuration,
style: DefaultTextStyle.of(context).style.copyWith(
color: design.colors.textColor,
fontSize: 28,
fontWeight: FontWeight.w500,
letterSpacing: -0.4),
child: !fiatFirst && balance.isEmpty
? MoneyText.optional(
accountBalance,
fractionalDigits: accountBalance?.decimals ?? 8,
showSymbol: false,
)
: Text(fiatFirst ? fiatBalance : balance),
),
AnimatedDefaultTextStyle(
duration: designSwitchDuration,
style: DefaultTextStyle.of(context).style.copyWith(
color: design.colors.textColorSecondary,
fontSize: 28,
fontWeight: FontWeight.w400,
letterSpacing: -0.4),
child: Text(resolvedAssetName),
),
],
);
},
child: Row(
key: ValueKey("$balance ${resolvedAssetName.toUpperCase()}"),
spacing: 8.0,
children: [
AnimatedDefaultTextStyle(
duration: designSwitchDuration,
style: DefaultTextStyle.of(context).style.copyWith(
color: design.colors.textColor,
fontSize: 28,
fontWeight: FontWeight.w500,
letterSpacing: -0.4),
child: Text(fiatFirst ? fiatBalance : balance),
),
AnimatedDefaultTextStyle(
duration: designSwitchDuration,
style: DefaultTextStyle.of(context).style.copyWith(
color: design.colors.textColorSecondary,
fontSize: 28,
fontWeight: FontWeight.w400,
letterSpacing: -0.4),
child: Text(resolvedAssetName),
),
],
),
),
),
),
@@ -203,17 +225,19 @@ class BalanceCard extends StatelessWidget {
child: AnimatedSwitcher(
duration: designSwitchDuration,
layoutBuilder: (currentChild, previousChildren) {
final currentKey = currentChild?.key;
return Stack(
alignment: Alignment.centerLeft,
children: <Widget>[
...previousChildren,
for (final child in previousChildren)
if (child.key != currentKey) child,
if (currentChild != null) currentChild,
],
);
},
child: Text(
key: ValueKey(fiatFirst ? balance : fiatBalance),
fiatFirst ? "$assetName $balance" : "$fiatCurrencyTitle $fiatBalance",
fiatFirst ? "$assetName $balance" : fiatBalance,
),
),
),
@@ -251,18 +275,12 @@ class BalanceCard extends StatelessWidget {
switchInCurve: Curves.easeInOut,
switchOutCurve: Curves.easeInOut,
child: design.backgroundType == CardDesignBackgroundTypes.svgIcon
? HashImageWidget(
imageUrl: design.imagePath,
key: const ValueKey('svgIcon'),
height: iconWidth,
width: iconWidth,
colorFilter: ColorFilter.mode(
design.colors.backgroundImageColor.withAlpha(80),
BlendMode.srcIn,
),
// Purely decorative card artwork.
? ExcludeSemantics(
child: _CornerSvgIcon(design: design, iconWidth: iconWidth),
)
: const SizedBox.shrink(
key: ValueKey('svgIconOff'),
key: ValueKey("svgIconOff"),
),
),
],
@@ -276,18 +294,28 @@ class BalanceCard extends StatelessWidget {
child: AnimatedOpacity(
duration: designSwitchDuration,
opacity: onCustomizeTapped == null ? 0 : 1,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onCustomizeTapped,
child: Container(
height: 40,
width: 40,
child: Center(
child: HashImageWidget(
imageUrl: "assets/new-ui/3dots_vertical.svg",
alignment: Alignment.topRight,
colorFilter:
ColorFilter.mode(design.colors.textColorSecondary, BlendMode.srcIn),
// Faded out and inert: must not be a focusable phantom control.
child: ExcludeSemantics(
excluding: onCustomizeTapped == null,
child: Semantics(
button: true,
label: S.of(context).wallet_menu,
onTap: onCustomizeTapped,
child: GestureDetector(
excludeFromSemantics: true,
behavior: HitTestBehavior.opaque,
onTap: onCustomizeTapped,
child: Container(
height: 40,
width: 40,
child: Center(
child: HashImageWidget(
imageUrl: "assets/new-ui/3dots_vertical.svg",
alignment: Alignment.topRight,
colorFilter:
ColorFilter.mode(design.colors.textColorSecondary, BlendMode.srcIn),
),
),
),
),
),
@@ -299,28 +327,58 @@ class BalanceCard extends StatelessWidget {
);
}
Widget getBalanceCardActionButton(BalanceCardAction action) => GestureDetector(
Widget getBalanceCardActionButton(BalanceCardAction action) => Semantics(
button: true,
label: action.label,
onTap: action.onTap,
child: Container(
decoration: BoxDecoration(
color: design.colors.backgroundImageColor.withAlpha(75),
borderRadius: BorderRadius.circular(10000000),
),
margin: const EdgeInsets.only(right: 10),
padding: const EdgeInsets.only(left: 10, right: 5, top: 5, bottom: 5),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.only(right: 6),
child: Text(
action.label,
style: TextStyle(color: design.colors.textColor, fontSize: 16),
),
child: ExcludeSemantics(
child: GestureDetector(
onTap: action.onTap,
child: Container(
decoration: BoxDecoration(
color: design.colors.backgroundImageColor.withAlpha(75),
borderRadius: BorderRadius.circular(10000000),
),
Icon(action.icon, color: design.colors.textColorSecondary, size: action.iconSize),
],
margin: const EdgeInsets.only(right: 10),
padding: const EdgeInsets.only(left: 10, right: 5, top: 5, bottom: 5),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.only(right: 6),
child: Text(
action.label,
style: TextStyle(color: design.colors.textColor, fontSize: 16),
),
),
Icon(action.icon, color: design.colors.textColorSecondary, size: action.iconSize),
],
),
),
),
),
);
}
class _CornerSvgIcon extends StatelessWidget {
const _CornerSvgIcon({required this.design, required this.iconWidth});
final CardDesign design;
final double iconWidth;
@override
Widget build(BuildContext context) {
return HashImageWidget(
imageUrl: design.imagePath,
key: ValueKey(design.imagePath),
height: iconWidth,
width: iconWidth,
colorFilter: design.preColoredIcon
? null
: ColorFilter.mode(
design.colors.backgroundImageColor.withValues(alpha: 0.33),
BlendMode.dstIn,
),
);
}
}
@@ -1,4 +1,6 @@
import 'dart:math';
import 'package:hash_wallet/core/utilities.dart';
import 'package:cw_core/balance_card_layout.dart';
import 'package:hash_wallet/bitcoin/bitcoin.dart';
import 'package:hash_wallet/di.dart';
@@ -8,11 +10,12 @@ 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/new-ui/widgets/buy_sell/buy_sell_selector_modal.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/utils/responsive_layout_util.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:hash_wallet/view_model/wallet_account_list/wallet_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';
@@ -29,14 +32,14 @@ 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});
required this.dashboardViewModel,
required this.accountListViewModel,
required this.lightningMode,
required this.onCompactModeBackgroundCardsTapped,
required this.onCustomizeTapped});
final DashboardViewModel dashboardViewModel;
final MoneroAccountListViewModel? accountListViewModel;
final WalletAccountListViewModel? accountListViewModel;
final VoidCallback onCompactModeBackgroundCardsTapped;
final VoidCallback onCustomizeTapped;
final bool lightningMode;
@@ -48,22 +51,39 @@ class CardsView extends StatefulWidget {
class _CardsViewState extends State<CardsView> {
late int _selectedIndex;
bool isFirstBuild = true;
ReactionDisposer? _cardOrderReaction;
int get _fallbackSelectedIndex =>
max(0, widget.dashboardViewModel.cardOrder.length - 1);
@override
void initState() {
super.initState();
_selectedIndex = widget.dashboardViewModel.cardOrder.length - 1;
reaction(
(_) => widget.dashboardViewModel.cardOrder.values.toList(),
(_) => setState(() {
_selectedIndex = widget.dashboardViewModel.cardOrder.length - 1;
}));
_selectedIndex = _fallbackSelectedIndex;
_cardOrderReaction = reaction(
(_) => widget.dashboardViewModel.cardOrder.values.toList(),
(_) {
if (mounted) setState(() => _selectedIndex = _fallbackSelectedIndex);
},
);
}
@override
void dispose() {
_cardOrderReaction?.call();
super.dispose();
}
static const Duration animDuration = Duration(milliseconds: 200);
static const int compactModeTreshold = 4;
static const int maxCards = 5;
/// No account name on the card: Lightning card, or Bitcoin with multi-accounts off.
bool get _hideAccountInfo =>
widget.lightningMode ||
(widget.dashboardViewModel.wallet.type == WalletType.bitcoin &&
!widget.dashboardViewModel.isMultiAccountsEnabled);
Widget _buildCard(int visualIndex, int realIndex, int numCards, double parentWidth,
Map<int, int> order, bool compactMode, double overlapAmount) {
final baseTop = overlapAmount * (numCards - 1);
@@ -74,11 +94,32 @@ class _CardsViewState extends State<CardsView> {
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;
final isSelected = _selectedIndex == visualIndex;
final accounts = widget.accountListViewModel?.accounts;
final account = accounts?.firstWhereOrNull((account) => account.id == realIndex);
final cardLabel = !_hideAccountInfo && account != null ? account.label : S.of(context).balance;
Future<void> onCardTap() async {
// printV(visualIndex);
if (compactMode && visualIndex != 0) {
widget.onCompactModeBackgroundCardsTapped();
} else if (!compactMode) {
if (account != null && !_hideAccountInfo) {
await widget.accountListViewModel!.select(account);
}
if (mounted) setState(() => _selectedIndex = visualIndex);
}
}
void onCardLongPress() {
if (_selectedIndex == visualIndex) {
widget.dashboardViewModel.balanceViewModel.switchBalanceValue();
}
HapticFeedback.heavyImpact();
}
return AnimatedPositioned(
key: ValueKey("$visualIndex $realIndex"),
duration: animDuration,
@@ -89,125 +130,117 @@ class _CardsViewState extends State<CardsView> {
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 card is the tap target; the balances and the card's own buttons stay
// reachable as children of this node.
child: Semantics(
button: true,
selected: isSelected,
label: cardLabel,
hint: isSelected
? (widget.dashboardViewModel.balanceViewModel.displayMode ==
BalanceDisplayMode.hiddenBalance
? S.of(context).long_press_show_balance
: S.of(context).long_press_hide_balance)
: null,
onTap: onCardTap,
onLongPress: isSelected ? onCardLongPress : null,
child: GestureDetector(
excludeFromSemantics: true,
onTap: onCardTap,
onLongPress: onCardLongPress,
child: Observer(builder: (_) {
final account = widget.accountListViewModel?.accounts
.firstWhereOrNull((account) => account.id == 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);
// 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 = '●●●●●●';
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?.combinedAvailableBalance ?? "0";
walletFiatBalance = walletBalanceRecord?.combinedFiatAvailableBalance ?? "0.00";
walletBalance = walletBalanceRecord?.availableBalance ?? "0";
walletFiatBalance = walletBalanceRecord?.fiatAvailableBalance ?? "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];
// the card designs is empty if widget gets built before it loads.
// should get populated before user sees anything
final designIndex = widget.dashboardViewModel.cardAccountIndices.indexOf(
widget.lightningMode ? -2 : realIndex,
);
final cardDesign = designIndex < 0 ||
designIndex >= widget.dashboardViewModel.cardDesigns.length
? CardDesign.genericDefault
: widget.dashboardViewModel.cardDesigns[designIndex];
final String accountName;
final String accountBalance;
if (account == null) {
accountName = "";
accountBalance = "";
} else {
accountName = account.label;
accountBalance = account.balance ?? "0.00";
}
final showAccount = account != null && !_hideAccountInfo;
final accountName = showAccount ? account.label : "";
final accountBalance = showAccount ? account.balance : null;
final assetName = widget.dashboardViewModel.balanceViewModel.showCombinedBalance
? ""
: walletBalanceRecord?.formattedAssetTitle ?? assetTitleFallback;
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),
)
]
: [];
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: () {
showModalBottomSheet(
context: context, builder: (context) => BuySellSelectorModal());
},
)
]
: [];
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,
);
}),
return BalanceCard(
width: effectiveCardWidth,
accountName: accountName,
accountBalance: accountBalance,
designSwitchDuration: Duration(milliseconds: 150),
assetName: assetName,
capitalizeAssetName: _shouldCapitalizeAssetName(),
balance: walletBalance,
fiatCurrencyTitle: walletBalanceRecord?.fiatCurrency?.title ??
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,
);
}),
),
),
),
);
@@ -234,45 +267,46 @@ class _CardsViewState extends State<CardsView> {
}
}
double get effectiveCardWidth => min(MediaQuery.of(context).size.width * 0.878,
responsiveLayoutUtil.shouldRenderMobileUI ? 768 : 512);
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);
/* height of initial card */
(2 / 3.2) * (effectiveCardWidth) +
/* height of bg card * amount of bg cards */
overlapAmount * ((numCards) - 1);
}
int? _visualIndexForSelectedAccount(Map<int, int> order) {
final vm = widget.accountListViewModel;
if (vm == null || _hideAccountInfo) return null;
final selectedId = vm.selectedAccount?.id;
for (final entry in order.entries) {
if (entry.value == selectedId) return entry.key;
}
return null;
}
@override
Widget build(BuildContext context) {
return Observer(builder: (context) {
Widget build(BuildContext context) => 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;
final stack = _hideAccountInfo
? const [0]
: BalanceCardLayout.stackFromOrder(widget.dashboardViewModel.cardOrder);
if (stack.isEmpty) return const SizedBox.shrink();
final numCards = stack.length;
if (_selectedIndex < 0 || _selectedIndex >= numCards) {
_selectedIndex = numCards - 1;
}
final order = {for (int i = 0; i < stack.length; i++) i: stack[i]};
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 followIndex = _visualIndexForSelectedAccount(order);
if (followIndex != null) _selectedIndex = followIndex;
final bool compactMode = numCards >= compactModeTreshold;
final double overlapAmount = compactMode ? 5.0 : 46.0;
@@ -281,14 +315,6 @@ class _CardsViewState extends State<CardsView> {
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));
}
@@ -306,7 +332,6 @@ class _CardsViewState extends State<CardsView> {
),
);
});
}
Future<void> depositToL2() async {
PaymentRequest? paymentRequest = null;
@@ -318,19 +343,19 @@ class _CardsViewState extends State<CardsView> {
}
} else if (widget.dashboardViewModel.type == WalletType.bitcoin) {
final depositAddress =
await bitcoin!.getUnusedSpakDepositAddress(widget.dashboardViewModel.wallet);
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) {
if (widget.dashboardViewModel.type == WalletType.bitcoin) {
final page = getIt.get<NewSendPage>(
param1: SendPageParams(
initialPaymentRequest: paymentRequest,
unspentCoinType: UnspentCoinType.nonMweb,
mode: SendPageModes.lightningDeposit,
));
initialPaymentRequest: paymentRequest,
unspentCoinType: UnspentCoinType.nonMweb,
mode: SendPageModes.lightningDeposit,
));
showCupertinoModalBottomSheet(
context: context,
barrierColor: Colors.black.withAlpha(128),
@@ -371,13 +396,13 @@ class _CardsViewState extends State<CardsView> {
unspentCoinType = UnspentCoinType.lightning;
}
if (FeatureFlag.hasNewUiExtraPages && widget.dashboardViewModel.type == WalletType.bitcoin) {
if (widget.dashboardViewModel.type == WalletType.bitcoin) {
final page = getIt.get<NewSendPage>(
param1: SendPageParams(
initialPaymentRequest: paymentRequest,
unspentCoinType: unspentCoinType,
mode: SendPageModes.lightningWithdrawal,
));
initialPaymentRequest: paymentRequest,
unspentCoinType: unspentCoinType,
mode: SendPageModes.lightningWithdrawal,
));
showCupertinoModalBottomSheet(
context: context,
barrierColor: Colors.black.withAlpha(128),
+51 -39
View File
@@ -21,51 +21,63 @@ class MwebAd extends StatelessWidget {
child: Column(
spacing: 12,
children: [
GestureDetector(
onTap: () => Navigator.of(context).pushNamed(Routes.mwebSettings),
child: Container(
height: 64,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(18),
color: Theme.of(context).colorScheme.surfaceContainer),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
HashImageWidget(
imageUrl: "assets/new-ui/settings_row_icons/mweb.svg",
width: 24,
height: 24,
),
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12.0),
child: Text(
S.of(context).mweb_ad,
softWrap: true,
style: TextStyle(fontSize: 12),
MergeSemantics(
child: Semantics(
button: true,
child: GestureDetector(
onTap: () => Navigator.of(context).pushNamed(Routes.mwebSettings),
child: Container(
height: 64,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(18),
color: Theme.of(context).colorScheme.surfaceContainer),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
ExcludeSemantics(
child: HashImageWidget(
imageUrl: "assets/new-ui/settings_row_icons/mweb.svg",
width: 24,
height: 24,
),
),
),
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12.0),
child: Text(
S.of(context).mweb_ad,
softWrap: true,
style: TextStyle(fontSize: 12),
),
),
),
Icon(
size: 16,
Icons.arrow_forward_ios,
color: Theme.of(context).colorScheme.primary,
)
],
),
Icon(
size: 16,
Icons.arrow_forward_ios,
color: Theme.of(context).colorScheme.primary,
)
],
),
),
),
),
),
GestureDetector(
onTap: () => dashboardViewModel.dismissMwebAd(false),
child: Text(
S.of(context).do_not_show_anymore,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: Theme.of(context).colorScheme.primary),
MergeSemantics(
child: Semantics(
button: true,
child: GestureDetector(
onTap: () => dashboardViewModel.dismissMwebAd(false),
child: Text(
S.of(context).do_not_show_anymore,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: Theme.of(context).colorScheme.primary),
),
),
),
)
],
@@ -0,0 +1,80 @@
import "package:hash_wallet/generated/i18n.dart";
import "package:hash_wallet/src/widgets/hash_image_widget.dart";
import "package:hash_wallet/view_model/dashboard/dashboard_view_model.dart";
import "package:flutter/material.dart";
import "package:flutter_mobx/flutter_mobx.dart";
class SeedBackupReminderCard extends StatelessWidget {
const SeedBackupReminderCard({required this.dashboardViewModel, required this.onTap, super.key});
final DashboardViewModel dashboardViewModel;
final VoidCallback onTap;
@override
Widget build(BuildContext context) => Observer(
builder: (_) {
if (!dashboardViewModel.shouldShowSeedBackupReminder) {
return const SizedBox.shrink();
}
final colors = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
return Padding(
padding: const EdgeInsets.only(left: 18, right: 18, top: 24),
child: MergeSemantics(
child: Semantics(
button: true,
child: GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: colors.surfaceContainer,
borderRadius: BorderRadius.circular(20),
),
child: Row(
children: [
ExcludeSemantics(
child: HashImageWidget(
imageUrl: "assets/new-ui/key.svg",
width: 24,
height: 24,
colorFilter: ColorFilter.mode(colors.primary, BlendMode.srcIn),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 4,
children: [
Text(
S.of(context).back_up_recovery_phrase,
style: textTheme.labelLarge?.copyWith(
color: colors.onSurface,
letterSpacing: -0.07,
),
),
Text(
S.of(context).back_up_recovery_phrase_description,
style: textTheme.bodySmall?.copyWith(
color: colors.onSurfaceVariant,
letterSpacing: -0.06,
),
),
],
),
),
const SizedBox(width: 8),
Icon(Icons.chevron_right, size: 16, color: colors.onSurfaceVariant),
],
),
),
),
),
),
);
},
);
}
@@ -0,0 +1,162 @@
import 'dart:async';
import 'dart:ui' as ui;
import 'package:hash_wallet/src/widgets/hash_image_widget.dart';
import 'package:cw_core/utils/print_verbose.dart';
import 'package:flutter/material.dart';
enum _IconShape {
alreadyCircular,
clipOnly,
hollowNeedsBackdrop,
}
class TokenImageWidget extends StatefulWidget {
const TokenImageWidget({
super.key,
required this.imageUrl,
required this.size,
this.errorWidget,
this.semanticsLabel,
});
final String imageUrl;
final double size;
final Widget? errorWidget;
/// Accessible name for the token artwork. Leave `null` (the default) when the
/// token is already named by adjacent text, so the image stays decorative.
final String? semanticsLabel;
@override
State<TokenImageWidget> createState() => _TokenImageWidgetState();
}
class _TokenImageWidgetState extends State<TokenImageWidget> {
static final Map<String, _IconShape> _shapeCache = {};
_IconShape _shape = _IconShape.clipOnly;
@override
void initState() {
super.initState();
_resolveShape();
}
@override
void didUpdateWidget(TokenImageWidget oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.imageUrl != widget.imageUrl) {
_resolveShape();
}
}
Future<void> _resolveShape() async {
final url = widget.imageUrl;
final cached = _shapeCache[url];
if (cached != null) {
if (mounted && _shape != cached) {
setState(() => _shape = cached);
}
return;
}
final shape = await _analyzeShape(url);
_shapeCache[url] = shape;
if (mounted) {
setState(() => _shape = shape);
}
}
Future<_IconShape> _analyzeShape(String url) async {
if (url.toLowerCase().endsWith('.svg')) return _IconShape.alreadyCircular;
final ImageProvider provider;
if (url.startsWith('assets/')) {
provider = AssetImage(url);
} else if (url.startsWith('http')) {
provider = NetworkImage(url);
} else {
return _IconShape.clipOnly;
}
try {
final completer = Completer<ui.Image>();
final stream = provider.resolve(ImageConfiguration.empty);
late final ImageStreamListener listener;
listener = ImageStreamListener(
(info, _) {
if (!completer.isCompleted) completer.complete(info.image);
},
onError: (error, _) {
if (!completer.isCompleted) completer.completeError(error);
},
);
stream.addListener(listener);
final image = await completer.future;
stream.removeListener(listener);
final byteData = await image.toByteData(format: ui.ImageByteFormat.rawRgba);
if (byteData == null) return _IconShape.clipOnly;
final totalPixels = image.width * image.height;
var transparentPixels = 0;
for (var i = 0; i < totalPixels; i++) {
final alpha = byteData.getUint8(i * 4 + 3);
if (alpha < 128) transparentPixels++;
}
final ratio = transparentPixels / totalPixels;
if (ratio > 0.15 && ratio < 0.30) {
return _IconShape.alreadyCircular;
}
if (ratio >= 0.30) {
return _IconShape.hollowNeedsBackdrop;
}
return _IconShape.clipOnly;
} catch (e) {
printV('TokenImageWidget: failed to analyze $url: $e');
return _IconShape.clipOnly;
}
}
@override
Widget build(BuildContext context) {
final image = SizedBox(
width: widget.size,
height: widget.size,
child: HashImageWidget(
imageUrl: widget.imageUrl,
width: widget.size,
height: widget.size,
fit: BoxFit.cover,
filterQuality: FilterQuality.high,
errorWidget: widget.errorWidget,
semanticsLabel: widget.semanticsLabel,
),
);
switch (_shape) {
case _IconShape.alreadyCircular:
return image;
case _IconShape.clipOnly:
return ClipOval(child: image);
case _IconShape.hollowNeedsBackdrop:
return Container(
width: widget.size,
height: widget.size,
clipBehavior: Clip.antiAlias,
decoration: const BoxDecoration(
shape: BoxShape.circle,
color: Colors.white,
),
child: image,
);
}
}
}
@@ -1,3 +1,4 @@
import 'package:hash_wallet/generated/i18n.dart';
import 'package:hash_wallet/src/widgets/hash_image_widget.dart';
import 'package:hash_wallet/view_model/dashboard/dashboard_view_model.dart';
import 'package:flutter/material.dart';
@@ -9,28 +10,36 @@ class ChainIcon extends StatelessWidget {
{super.key,
required this.iconPath,
required this.dashboardViewModel,
required this.isSyncHeavy});
required this.isSyncHeavy,
required this.showSyncedMessage});
final String iconPath;
final bool isSyncHeavy;
final DashboardViewModel dashboardViewModel;
final bool showSyncedMessage;
@override
Widget build(BuildContext context) {
return Observer(
builder: (_) {
final progress = dashboardViewModel.status.progress();
final done = !isSyncHeavy || progress >= 1;
final done = !showSyncedMessage && (!isSyncHeavy || progress >= 1);
return Stack(
children: [
AnimatedOpacity(
duration: Duration(milliseconds: 100),
opacity: done ? 0 : 1,
child: CircularProgressIndicator(
value: progress,
color: Color(0xFFFFB84E),
strokeWidth: 2,
// Faded out means "nothing to report", so it must leave the tree too.
child: ExcludeSemantics(
excluding: done || showSyncedMessage,
child: CircularProgressIndicator(
value: progress,
color: showSyncedMessage ? Color(0xFFFF12A439) : Color(0xFFFFB84E),
strokeWidth: 2,
semanticsLabel: S.of(context).synchronizing,
semanticsValue: "${(progress * 100).round()}%",
),
),
),
AnimatedScale(
@@ -1,3 +1,4 @@
import 'package:hash_wallet/generated/i18n.dart';
import 'package:hash_wallet/src/widgets/hash_image_widget.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
@@ -12,47 +13,50 @@ class LightningSwitcher extends StatelessWidget {
@override
Widget build(BuildContext context) {
return SizedBox(
child: InkWell(
onTap: () {
HapticFeedback.mediumImpact();
onLightningSwitchPress();
},
child: Container(
decoration: ShapeDecoration(
shape: RoundedSuperellipseBorder(borderRadius: BorderRadiusGeometry.circular(900.0)),
color: Theme.of(context).colorScheme.surfaceContainer),
width: 70,
height: 36,
padding: EdgeInsets.symmetric(vertical: 2),
child: Stack(
children: [
AnimatedContainer(
alignment: Alignment.centerRight,
margin: EdgeInsets.only(left: lightningMode ? 36 : 2),
duration: Duration(milliseconds: 250),
curve: Curves.easeOutCubic,
width: 32,
height: 32,
// height: double.infinity,
decoration: BoxDecoration(
borderRadius: BorderRadius.all(Radius.circular(9999990.0)),
color: Theme.of(context).colorScheme.primary),
),
Container(
child: Row(
spacing: 2.0,
// One toggle node: the knob position and the coloured glyphs are the only
// visual cue for which mode is active.
return Semantics(
button: true,
toggled: lightningMode,
label: S.of(context).lightning_mode,
child: SizedBox(
child: InkWell(
onTap: () {
HapticFeedback.mediumImpact();
onLightningSwitchPress();
},
child: Container(
decoration: ShapeDecoration(
shape:
RoundedSuperellipseBorder(borderRadius: BorderRadiusGeometry.circular(900.0)),
color: Theme.of(context).colorScheme.surfaceContainer),
width: 63,
height: 36,
padding: const EdgeInsets.all(4.5),
child: Stack(
children: [
AnimatedContainer(
alignment: Alignment.centerRight,
margin: EdgeInsets.only(left: lightningMode ? 27 : 0),
duration: const Duration(milliseconds: 250),
curve: Curves.easeOutCubic,
width: 27,
height: 27,
decoration: BoxDecoration(
borderRadius: BorderRadius.all(Radius.circular(9999990.0)),
color: Theme.of(context).colorScheme.primary),
),
Row(
children: [
SizedBox(),
AnimatedSwitcher(
duration: Duration(milliseconds: 150),
duration: const Duration(milliseconds: 150),
transitionBuilder: (child, animation) =>
FadeTransition(opacity: animation, child: child),
child: HashImageWidget(
imageUrl: 'assets/new-ui/switcher-bitcoin.svg',
imageUrl: "assets/new-ui/switcher-bitcoin.svg",
key: ValueKey(lightningMode),
width: 32,
height: 32,
width: 27,
height: 27,
colorFilter: ColorFilter.mode(
lightningMode
? Theme.of(context).colorScheme.primary
@@ -62,14 +66,14 @@ class LightningSwitcher extends StatelessWidget {
),
),
AnimatedSwitcher(
duration: Duration(milliseconds: 150),
duration: const Duration(milliseconds: 150),
transitionBuilder: (child, animation) =>
FadeTransition(opacity: animation, child: child),
child: HashImageWidget(
imageUrl: 'assets/new-ui/switcher-lightning.svg',
imageUrl: "assets/new-ui/switcher-lightning.svg",
key: ValueKey(lightningMode),
width: 32,
height: 32,
width: 27,
height: 27,
colorFilter: ColorFilter.mode(
lightningMode
? Theme.of(context).colorScheme.surfaceContainer
@@ -80,8 +84,8 @@ class LightningSwitcher extends StatelessWidget {
),
],
),
),
],
],
),
),
),
),
@@ -1,20 +1,27 @@
import 'package:hash_wallet/core/sync_status_title.dart';
import 'package:hash_wallet/di.dart';
import 'package:hash_wallet/new-ui/widgets/coins_page/top_bar_widget/pulsing_dot.dart';
import 'package:hash_wallet/generated/i18n.dart';
import 'package:hash_wallet/src/screens/settings/manage_nodes_page.dart';
import 'package:hash_wallet/src/widgets/hash_image_widget.dart';
import "package:hash_wallet/themes/core/theme_extension.dart";
import 'package:hash_wallet/view_model/dashboard/dashboard_view_model.dart';
import 'package:cw_core/sync_status.dart';
import "package:flutter/cupertino.dart";
import 'package:flutter/material.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:modal_bottom_sheet/modal_bottom_sheet.dart';
class SyncBar extends StatelessWidget {
SyncBar({super.key, required this.dashboardViewModel, required this.isSyncHeavy});
const SyncBar({
required this.dashboardViewModel,
required this.isSyncHeavy,
required this.showSyncedMessage,
super.key,
});
final DashboardViewModel dashboardViewModel;
final bool isSyncHeavy;
final bool showSyncedMessage;
static const failStatuses = [
FailedSyncStatus,
@@ -36,87 +43,170 @@ class SyncBar extends StatelessWidget {
ConnectedSyncStatus,
];
@override
Widget build(BuildContext context) {
return Observer(
builder: (_) {
final status = dashboardViewModel.status;
final Widget? icon = _getIcon(context, status.runtimeType);
static const syncedColor = Color(0xFF12A439);
return Expanded(
child: Stack(
alignment: Alignment.centerLeft,
children: [
if (!_showFullBar())
Row(
mainAxisSize: MainAxisSize.min,
spacing: 6,
children: [
if (dashboardViewModel.isTorEnabled)
HashImageWidget(
imageUrl: "assets/new-ui/tor.svg",
width: 20,
height: 20,
),
if (_showDot()) PulsingDot(),
],
),
if (_showFullBar())
GestureDetector(
onTap: () {
CupertinoScaffold.showCupertinoModalBottomSheet(
context: context,
barrierColor: Colors.black.withAlpha(85),
builder: (context) => FractionallySizedBox(
child: Material(
child: getIt.get<ManageNodesPage>(param1: false),
)));
},
child: AnimatedSwitcher(
duration: Duration(milliseconds: 100),
@override
Widget build(BuildContext context) => Observer(
builder: (_) {
final status = dashboardViewModel.status;
if (!_showFullBar) {
return _buildCompactBar(context);
}
final Widget? icon = _getIcon(context, status.runtimeType);
final statusTitle = _statusTitle(context, status);
final silentPaymentsProgress = _silentPaymentsProgress(status);
final barHeight = silentPaymentsProgress == null ? 36.0 : 40.0;
// A single node: the localized status text (plus any active
// Tor/MWEB/Silent Payments badge) is the label, and the hint says
// where tapping leads. Everything inside is redundant with it.
return Semantics(
button: true,
label: _statusSemanticsLabel(context, status, statusTitle),
value: silentPaymentsProgress,
hint: S.of(context).manage_nodes,
onTap: () => _openNodeManagement(context),
child: ExcludeSemantics(
child: GestureDetector(
onTap: () => _openNodeManagement(context),
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 100),
child: OverflowBox(
key: ValueKey(status.runtimeType),
alignment: Alignment.center,
minHeight: barHeight,
maxHeight: barHeight,
child: Container(
key: ValueKey(status.runtimeType),
height: 36,
height: barHeight,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(9999),
border: _getBorder(context, status.runtimeType),
color: _getBackgroundColor(context, status.runtimeType),
),
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Row(
spacing: 10,
spacing: 8,
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisSize: MainAxisSize.max,
children: [
if (icon != null) icon,
// if (dashboardViewModel.silentPaymentsScanningActive &&
// progressStatuses.contains(status.runtimeType)) ...[
// Text(
// "${(status.progress() * 100).toInt()}%",
// style: TextStyle(fontSize: 12, color: Color(0xFFEFBA5E)),
// ),
// Text(
// "·",
// style: TextStyle(fontSize: 12),
// )
// ],
Text(
syncStatusTitle(
status, dashboardViewModel.settingsStore.syncStatusDisplayMode),
style: _getTextStyle(context, status.runtimeType),
if (silentPaymentsProgress != null) ...[
Text(
silentPaymentsProgress,
style: TextStyle(
fontSize: 12,
color: context.customColors.warningOutlineColor,
),
),
Text(
"·",
style: TextStyle(
fontSize: 12,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
Flexible(
child: Text(
statusTitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: _getTextStyle(context, status.runtimeType),
),
),
],
),
),
),
),
],
),
);
},
),
),
);
},
);
void _openNodeManagement(BuildContext context) {
CupertinoScaffold.showCupertinoModalBottomSheet(
context: context,
barrierColor: Colors.black.withAlpha(85),
builder: (context) => FractionallySizedBox(
child: Material(
child: getIt.get<ManageNodesPage>(param1: false),
),
),
);
}
/// Compact mode shows sync state with a pulsing dot (and a Tor glyph) only, so
/// the whole row needs a text equivalent.
Widget _buildCompactBar(BuildContext context) {
final row = Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.start,
spacing: 6,
children: [
if (dashboardViewModel.isTorEnabled)
const HashImageWidget(
imageUrl: "assets/new-ui/tor.svg",
width: 20,
height: 20,
),
if (_showDot())
CupertinoActivityIndicator(
key: ValueKey(dashboardViewModel.status.runtimeType),
radius: 8,
),
if (_showLightSyncCheck()) const Icon(Icons.check, color: syncedColor, size: 18),
],
);
final label = _joinLabels([
if (dashboardViewModel.isTorEnabled) S.of(context).tor_connection,
if (_showDot()) S.of(context).synchronizing,
if (_showLightSyncCheck()) S.of(context).sync_synced,
]);
if (label.isEmpty) {
return row;
}
return Semantics(label: label, child: ExcludeSemantics(child: row));
}
bool get _isShowingSyncedMessage =>
showSyncedMessage && dashboardViewModel.status.runtimeType == SyncedSyncStatus;
String _statusTitle(BuildContext context, SyncStatus status) {
final title = syncStatusTitle(status, dashboardViewModel.settingsStore.syncStatusDisplayMode);
return title.isNotEmpty ? title : S.of(context).synchronizing;
}
String _statusSemanticsLabel(BuildContext context, SyncStatus status, String statusTitle) {
final isFailure = failStatuses.contains(status.runtimeType);
return _joinLabels([
statusTitle,
if (!isFailure && dashboardViewModel.isTorEnabled) S.of(context).tor_connection,
if (!isFailure && dashboardViewModel.hasMweb) S.of(context).litecoin_mweb,
if (!isFailure && dashboardViewModel.hasSilentPayments) S.of(context).silent_payments,
]);
}
String _joinLabels(List<String> parts) => parts.where((part) => part.isNotEmpty).join(", ");
String? _silentPaymentsProgress(SyncStatus status) {
if (!dashboardViewModel.hasSilentPayments ||
!dashboardViewModel.silentPaymentsScanningActive ||
status is! SyncingSyncStatus) {
return null;
}
return "${(status.progress() * 100).toInt()}%";
}
Color? _getBackgroundColor(BuildContext context, Type status) {
if (failStatuses.contains(status)) {
return Theme.of(context).colorScheme.errorContainer.withAlpha(64);
@@ -126,7 +216,7 @@ class SyncBar extends StatelessWidget {
}
Border? _getBorder(BuildContext context, Type status) {
if (progressStatuses.contains(status)) {
if (progressStatuses.contains(status) || _isShowingSyncedMessage) {
return Border.all(color: Theme.of(context).colorScheme.surfaceContainerHigh, width: 1);
}
@@ -134,19 +224,24 @@ class SyncBar extends StatelessWidget {
}
TextStyle? _getTextStyle(BuildContext context, Type status) {
final Color color;
if (failStatuses.contains(status)) {
return TextStyle(
fontSize: 12, fontWeight: FontWeight.w400, color: Theme.of(context).colorScheme.error);
color = Theme.of(context).colorScheme.error;
} else if (status == SyncedSyncStatus) {
color = syncedColor;
} else {
return TextStyle(
fontSize: 12,
fontWeight: FontWeight.w400,
color: Theme.of(context).colorScheme.onSurfaceVariant);
color = Theme.of(context).colorScheme.onSurfaceVariant;
}
return TextStyle(fontSize: 12, fontWeight: FontWeight.w400, color: color);
}
Widget? _getIcon(BuildContext context, Type status) {
if (status == LostConnectionSyncStatus || status == FailedSyncStatus) {
if (status == SyncedSyncStatus) {
return const Icon(Icons.check, color: syncedColor, size: 12);
}
if (status == LostConnectionSyncStatus) {
return HashImageWidget(
imageUrl: "assets/new-ui/offline.svg",
colorFilter: ColorFilter.mode(Theme.of(context).colorScheme.error, BlendMode.srcIn),
@@ -163,22 +258,35 @@ class SyncBar extends StatelessWidget {
final List<Widget> children = [];
if (dashboardViewModel.isTorEnabled) {
children.add(HashImageWidget(
children.add(
const HashImageWidget(
imageUrl: "assets/new-ui/tor_sync.svg",
colorFilter: ColorFilter.mode(Color(0xFF8A38F5), BlendMode.srcIn)));
colorFilter: ColorFilter.mode(Color(0xFF8A38F5), BlendMode.srcIn),
),
);
}
if (dashboardViewModel.hasMweb) {
children.add(HashImageWidget(
imageUrl: "assets/new-ui/mweb_sync.svg",
colorFilter:
ColorFilter.mode(Theme.of(context).colorScheme.onSurfaceVariant, BlendMode.srcIn),
));
children.add(
HashImageWidget(
imageUrl: "assets/new-ui/mweb_sync.svg",
colorFilter:
ColorFilter.mode(Theme.of(context).colorScheme.onSurfaceVariant, BlendMode.srcIn),
),
);
}
if (dashboardViewModel.hasSilentPayments) {
children.add(HashImageWidget(
imageUrl: "assets/new-ui/silent_sync.svg",
colorFilter: ColorFilter.mode(Color(0xFFEFBA5E), BlendMode.srcIn),
));
children.add(
HashImageWidget(
imageUrl: "assets/new-ui/silent_sync.svg",
width: 16,
height: 16,
colorFilter: ColorFilter.mode(context.customColors.warningOutlineColor, BlendMode.srcIn),
),
);
}
if (children.isEmpty) {
return null;
}
return Row(
@@ -187,12 +295,16 @@ class SyncBar extends StatelessWidget {
);
}
bool _showFullBar() {
if (dashboardViewModel.status.runtimeType == SyncedSyncStatus) return false;
bool get _showFullBar {
if (dashboardViewModel.status.runtimeType == SyncedSyncStatus) {
return isSyncHeavy && _isShowingSyncedMessage;
}
return isSyncHeavy || failStatuses.contains(dashboardViewModel.status.runtimeType);
}
bool _showDot() {
return !isSyncHeavy && progressStatuses.contains(dashboardViewModel.status.runtimeType);
}
bool _showDot() =>
!isSyncHeavy && progressStatuses.contains(dashboardViewModel.status.runtimeType);
bool _showLightSyncCheck() => !isSyncHeavy && _isShowingSyncedMessage;
}
@@ -1,73 +1,193 @@
import 'dart:async';
import 'dart:io';
import 'package:hash_wallet/generated/i18n.dart';
import 'package:hash_wallet/new-ui/widgets/coins_page/top_bar_widget/chain_icon.dart';
import 'package:hash_wallet/new-ui/widgets/coins_page/top_bar_widget/lightning_switcher.dart';
import 'package:hash_wallet/new-ui/widgets/coins_page/top_bar_widget/sync_bar.dart';
import "package:hash_wallet/new-ui/widgets/coins_page/wallet_info.dart";
import 'package:hash_wallet/new-ui/widgets/modern_button.dart';
import 'package:hash_wallet/view_model/dashboard/dashboard_view_model.dart';
import 'package:cw_core/sync_status.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:mobx/mobx.dart';
class TopBar extends StatelessWidget {
class TopBar extends StatefulWidget {
const TopBar({
super.key,
required this.lightningMode,
required this.onLightningSwitchPress,
required this.dashboardViewModel,
required this.onSettingsButtonPress,
super.key,
});
final bool lightningMode;
final VoidCallback onLightningSwitchPress;
final VoidCallback onSettingsButtonPress;
final DashboardViewModel dashboardViewModel;
@override
Widget build(BuildContext context) {
return Padding(
padding: EdgeInsets.only(
bottom: 10, left: 18, right: 18, top: 10 + _additionalTopPadding(context)),
child: Observer(
builder: (_) => Row(
spacing: 12,
children: [
(dashboardViewModel.hasLightning)
? LightningSwitcher(
lightningMode: lightningMode,
onLightningSwitchPress: onLightningSwitchPress,
)
: ChainIcon(
iconPath: dashboardViewModel.wallet.currency.flatIconPath ?? "",
dashboardViewModel: dashboardViewModel,
isSyncHeavy: dashboardViewModel.isSyncHeavy),
SyncBar(
dashboardViewModel: dashboardViewModel,
isSyncHeavy: dashboardViewModel.isSyncHeavy,
),
ModernButton.svg(
// Hash Bags: was theme.colorScheme.primary (dark forest green)
// which rendered the gear icon nearly invisible on the dark
// dashboard backdrop. onSurfaceVariant is the de-emphasized-
// foreground Material token.
iconColor: Theme.of(context).colorScheme.onSurfaceVariant,
size: 36,
onPressed: () {
HapticFeedback.mediumImpact();
onSettingsButtonPress();
},
svgPath: "assets/new-ui/top-settings.svg",
),
],
),
),
State<TopBar> createState() => _TopBarState();
}
class _TopBarState extends State<TopBar> {
static const syncedMessageDuration = Duration(seconds: 3);
bool showSyncedMessage = false;
Timer? syncedMessageTimer;
ReactionDisposer? _statusReactionDisposer;
bool get replacesWalletName {
final status = widget.dashboardViewModel.status.runtimeType;
if (status == SyncedSyncStatus) {
return showSyncedMessage;
}
return widget.dashboardViewModel.isSyncHeavy ||
SyncBar.progressStatuses.contains(status) ||
SyncBar.failStatuses.contains(status);
}
@override
void initState() {
super.initState();
_bindStatusReaction();
}
@override
void didUpdateWidget(covariant TopBar oldWidget) {
super.didUpdateWidget(oldWidget);
if (identical(oldWidget.dashboardViewModel, widget.dashboardViewModel)) {
return;
}
syncedMessageTimer?.cancel();
syncedMessageTimer = null;
showSyncedMessage = false;
_statusReactionDisposer?.call();
_bindStatusReaction();
}
void _bindStatusReaction() {
_statusReactionDisposer = reaction(
(_) => widget.dashboardViewModel.status.runtimeType,
(status) {
syncedMessageTimer?.cancel();
syncedMessageTimer = null;
if (status == SyncedSyncStatus) {
if (mounted) {
setState(() => showSyncedMessage = true);
}
syncedMessageTimer = Timer(syncedMessageDuration, () {
syncedMessageTimer = null;
if (mounted) {
setState(() => showSyncedMessage = false);
}
});
} else {
if (mounted) {
setState(() => showSyncedMessage = false);
}
}
},
);
}
@override
void dispose() {
syncedMessageTimer?.cancel();
syncedMessageTimer = null;
_statusReactionDisposer?.call();
super.dispose();
}
@override
Widget build(BuildContext context) => Padding(
padding: EdgeInsets.only(
bottom: 10, left: 18, right: 18, top: 10 + _additionalTopPadding(context)),
child: Observer(
builder: (_) => Row(
spacing: 12,
children: [
widget.dashboardViewModel.hasLightning
? LightningSwitcher(
lightningMode: widget.dashboardViewModel.lightningMode,
onLightningSwitchPress: widget.onLightningSwitchPress,
)
: ChainIcon(
iconPath: widget.dashboardViewModel.wallet.currency.flatIconPath ?? "",
dashboardViewModel: widget.dashboardViewModel,
isSyncHeavy: widget.dashboardViewModel.isSyncHeavy,
showSyncedMessage: showSyncedMessage,
),
Expanded(
child: SizedBox(
height: 36,
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 150),
reverseDuration: Duration.zero,
layoutBuilder: (currentChild, previousChildren) => Stack(
alignment: Alignment.centerLeft,
children: [
...previousChildren,
if (currentChild != null) currentChild,
],
),
child: replacesWalletName
? SyncBar(
dashboardViewModel: widget.dashboardViewModel,
isSyncHeavy: widget.dashboardViewModel.isSyncHeavy,
showSyncedMessage: showSyncedMessage,
)
: Row(
children: [
Expanded(
child: WalletInfoBar(
name: widget.dashboardViewModel.wallet.name,
hardwareWalletType: widget.dashboardViewModel.wallet.hardwareWalletType,
),
),
if (widget.dashboardViewModel.isTorEnabled) ...[
const SizedBox(width: 6),
SyncBar(
dashboardViewModel: widget.dashboardViewModel,
isSyncHeavy: widget.dashboardViewModel.isSyncHeavy,
showSyncedMessage: showSyncedMessage,
),
],
],
),
),
),
),
ModernButton.svg(
key: const ValueKey("home_page_settings_button_key"),
// Hash Bags: was theme.colorScheme.primary (dark forest green)
// which rendered the gear icon nearly invisible on the dark
// dashboard backdrop. onSurfaceVariant is the de-emphasized-
// foreground Material token.
iconColor: Theme.of(context).colorScheme.onSurfaceVariant,
size: 36,
onPressed: () {
HapticFeedback.mediumImpact();
widget.onSettingsButtonPress();
},
svgPath: "assets/new-ui/top-settings.svg",
semanticLabel: S.of(context).settings_title,
),
],
),
),
);
//FIXME remove after this gets fixed flutter-side
double _additionalTopPadding(BuildContext context) {
if (Platform.isIOS && MediaQuery.of(context).viewPadding.top < 12) return 24;
if (Platform.isIOS && MediaQuery.of(context).viewPadding.top < 12) {
return 24;
}
return 0;
}
}
}
@@ -1,8 +1,10 @@
import 'package:hash_wallet/generated/i18n.dart';
import "package:hash_wallet/new-ui/widgets/coins_page/zcash_migration_modal.dart";
import 'package:hash_wallet/new-ui/widgets/new_primary_button.dart';
import 'package:hash_wallet/new-ui/widgets/receive_page/receive_top_bar.dart';
import 'package:hash_wallet/src/widgets/hash_image_widget.dart';
import 'package:hash_wallet/view_model/dashboard/dashboard_view_model.dart';
import "package:flutter/cupertino.dart";
import 'package:flutter/material.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:modal_bottom_sheet/modal_bottom_sheet.dart';
@@ -21,89 +23,94 @@ class UnconfirmedBalanceWidget extends StatelessWidget {
return Observer(builder: (_) {
final show = dashboardViewModel.balanceViewModel
.hasAdditionalBalance(dashboardViewModel.wallet.currency);
final isIronwoodMigration = dashboardViewModel.isMigratingToIronwood;
return AnimatedSize(
duration: const Duration(milliseconds: 300),
curve: Curves.easeOutCubic,
alignment: Alignment.topCenter,
child: show
? Column(
children: [
const SizedBox(height: 12, width: double.infinity),
Observer(builder: (context) {
final balance =
dashboardViewModel.balanceViewModel.additionalBalance(currency);
duration: const Duration(milliseconds: 300),
curve: Curves.easeOutCubic,
alignment: Alignment.topCenter,
child: show
? Column(
children: [
const SizedBox(height: 12, width: double.infinity),
Observer(builder: (context) {
final balance = dashboardViewModel.balanceViewModel.additionalBalance(currency);
return Container(
width: MediaQuery.of(context).size.width * 0.87,
height: 48,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainer,
borderRadius: BorderRadius.circular(16.0),
),
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: () {
showMaterialModalBottomSheet(
backgroundColor: Colors.transparent,
context: context,
builder: (context) {
return UnconfirmedBalanceModal(
balance: "${balance} ${currency.title}",
currencyIconPath: currency.iconPath ?? "",
);
});
},
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
spacing: 12,
children: [
SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
backgroundColor:
Theme.of(context).colorScheme.primary.withAlpha(50),
color: Theme.of(context).colorScheme.primary,
value: dashboardViewModel.confirmationProgress,
),
),
Row(
spacing: 4,
children: [
Text(
"$balance ${currency.title}",
style: TextStyle(
return Container(
width: MediaQuery.of(context).size.width * 0.87,
height: 48,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainer,
borderRadius: BorderRadius.circular(16.0),
),
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: () {
final modal = isIronwoodMigration
? ZcashMigrationModal(
hasContinue: false, balance: "${balance} ${currency.symbol}")
: UnconfirmedBalanceModal(
balance: balance.toStringWithSymbol(),
currencyIconPath: currency.iconPath ?? "",
);
showMaterialModalBottomSheet(
backgroundColor: Colors.transparent,
context: context,
builder: (context) => modal);
},
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
spacing: 12,
children: [
SizedBox(
height: 20,
width: 20,
child: isIronwoodMigration
? CupertinoActivityIndicator(
color: Theme.of(context).colorScheme.primary,
),
)
: CircularProgressIndicator(
strokeWidth: 2,
backgroundColor:
Theme.of(context).colorScheme.primary.withAlpha(50),
color: Theme.of(context).colorScheme.primary,
value: dashboardViewModel.confirmationProgress,
),
),
Row(
spacing: 4,
children: [
Text(
"$balance ${currency.title}",
style: TextStyle(
color: Theme.of(context).colorScheme.primary,
),
Text(S.of(context).confirming),
],
)
],
),
Icon(
Icons.chevron_right,
color: Theme.of(context).colorScheme.onSurfaceVariant,
)
],
),
),
Text(isIronwoodMigration ? S.of(context).migrating_to_ironwood : S.of(context).confirming),
],
)
],
),
Icon(
Icons.chevron_right,
color: Theme.of(context).colorScheme.onSurfaceVariant,
)
],
),
),
),
);
}),
],
)
: const SizedBox(
width: double.infinity,
));
),
);
}),
],
)
: const SizedBox(width: double.infinity),
);
});
}
}
@@ -129,6 +136,7 @@ class UnconfirmedBalanceModal extends StatelessWidget {
ModalTopBar(
title: S.of(context).balance_confirmation,
leadingIcon: Icon(Icons.close),
leadingSemanticLabel: S.of(context).close,
onLeadingPressed: Navigator.of(context).pop),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
@@ -175,3 +183,4 @@ class UnconfirmedBalanceModal extends StatelessWidget {
);
}
}
+48 -68
View File
@@ -1,84 +1,64 @@
import 'package:hash_wallet/new-ui/widgets/modern_button.dart';
import 'package:hash_wallet/generated/i18n.dart';
import 'package:hash_wallet/src/widgets/hash_image_widget.dart';
import 'package:cw_core/wallet_info.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_svg/flutter_svg.dart';
class WalletInfoBar extends StatelessWidget {
const WalletInfoBar(
{super.key,
required this.lightningMode,
required this.name,
required this.hardwareWalletType,
required this.onCustomizeButtonTap,
required this.hasCustomize});
const WalletInfoBar({required this.name, required this.hardwareWalletType, super.key});
final bool lightningMode;
final String name;
final HardwareWalletType? hardwareWalletType;
final bool hasCustomize;
final VoidCallback onCustomizeButtonTap;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () {
if (hasCustomize) {
onCustomizeButtonTap();
HapticFeedback.mediumImpact();
}
},
child: Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
AnimatedSwitcher(
duration: Duration(milliseconds: 150),
transitionBuilder: (child, animation) {
return SizeTransition(
final semanticsLabel =
hardwareWalletType == null ? name : "$name, ${S.of(context).hardware_wallet}";
return Semantics(
label: semanticsLabel,
child: ExcludeSemantics(
child: Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
AnimatedSwitcher(
duration: const Duration(milliseconds: 150),
transitionBuilder: (child, animation) => SizeTransition(
axis: Axis.horizontal,
sizeFactor: animation,
child: FadeTransition(opacity: animation, child: child),
);
},
child: hardwareWalletIcon == null
? const SizedBox.shrink(key: ValueKey("empty"))
: Padding(
padding: const EdgeInsets.only(right: 8),
child: HashImageWidget(
imageUrl: hardwareWalletIcon!,
key: ValueKey("hardware_wallet_icon"),
width: 24,
height: 24,
colorFilter: ColorFilter.mode(
Theme.of(context).colorScheme.onSurfaceVariant,
BlendMode.srcIn,
),
child: hardwareWalletIcon == null
? const SizedBox.shrink(key: ValueKey("empty"))
: Padding(
padding: const EdgeInsets.only(right: 8),
child: HashImageWidget(
imageUrl: hardwareWalletIcon!,
key: const ValueKey("hardware_wallet_icon"),
width: 24,
height: 24,
colorFilter: ColorFilter.mode(
Theme.of(context).colorScheme.onSurfaceVariant,
BlendMode.srcIn,
),
),
),
),
),
Text(
name,
style: Theme.of(context)
.textTheme
.titleLarge
?.copyWith(color: Theme.of(context).colorScheme.onSurface),
),
if (hasCustomize) ...[
SizedBox(width: 8),
ModernButton.svg(
size: 24,
onPressed: () {
if (hasCustomize) {
onCustomizeButtonTap();
HapticFeedback.mediumImpact();
}
},
svgPath: "assets/new-ui/icon-accounts.svg",
)
]
],
),
Expanded(
child: Text(
name,
key: const ValueKey("home_page_wallet_name_text_key"),
maxLines: 1,
softWrap: false,
overflow: TextOverflow.ellipsis,
style: Theme.of(context)
.textTheme
.titleMedium
?.copyWith(color: Theme.of(context).colorScheme.onSurface),
),
),
],
),
),
);
}
@@ -88,11 +68,11 @@ class WalletInfoBar extends StatelessWidget {
case null:
return null;
case HardwareWalletType.bitbox:
return "assets/images/hardware_wallet/device_bitbox.svg";
return "assets/new-ui/hardware_wallets/device_bitbox.svg";
case HardwareWalletType.ledger:
return "assets/images/hardware_wallet/device_ledger_nano_x.svg";
return "assets/new-ui/hardware_wallets/device_ledger_nano_x.svg";
case HardwareWalletType.trezor:
return "assets/images/hardware_wallet/device_trezor_safe_5.svg";
return "assets/new-ui/hardware_wallets/device_trezor_safe_5.svg";
case HardwareWalletType.cupcake:
return "assets/images/cupcake.svg";
case HardwareWalletType.coldcard:
@@ -0,0 +1,152 @@
import "package:hash_wallet/generated/i18n.dart";
import "package:hash_wallet/new-ui/widgets/new_primary_button.dart";
import "package:hash_wallet/new-ui/widgets/receive_page/receive_top_bar.dart";
import "package:hash_wallet/src/widgets/hash_image_widget.dart";
import "package:flutter/material.dart";
import "package:url_launcher/url_launcher.dart";
class ZcashMigrationModal extends StatelessWidget {
const ZcashMigrationModal({required this.hasContinue, required this.balance, super.key});
final String balance;
final bool hasContinue;
@override
Widget build(BuildContext context) => SafeArea(
bottom: false,
child: Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: const BorderRadius.vertical(top: Radius.circular(16)),),
child: SafeArea(
top: false,
child: Column(
children: [
ModalTopBar(
title: S.of(context).zcash_network_upgrade,
trailingIcon: const Icon(Icons.close),
onTrailingPressed: Navigator.of(context).pop,
trailingSemanticLabel: S.of(context).close,
),
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
children: [
Expanded(
child: Column(
spacing: 40,
mainAxisAlignment: MainAxisAlignment.center,
children: [
SizedBox(
width: 146,
height: 79,
child: Stack(
children: [
const Positioned(
left: 67,
top: 0,
bottom: 0,
child: HashImageWidget(
imageUrl: "assets/new-ui/ironwood_migration.svg",
width: 75,
height: 75,
),
),
Positioned(
left: 0,
child: Container(
width: 79,
height: 79,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(99999999),
border: Border.all(
color: Theme.of(context).colorScheme.surface,
width: 4,),),
child: const HashImageWidget(
imageUrl: "assets/new-ui/crypto_full_icons/zcash.svg",
height: 75,
width: 75,
),
),
),
],
),
),
Container(
width: double.infinity,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
color: Theme.of(context).colorScheme.surfaceContainer,),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
spacing: 4,
children: [
Text(
balance,
style: TextStyle(
color: Theme.of(context).colorScheme.primary,),
),
Text(S.of(context).is_currently_being_migrated),
],
),
),),
RichText(
textAlign: TextAlign.center,
text: TextSpan(
children: [
TextSpan(
text: "${S.of(context).zcash_network_upgrade_desc_1}\n\n",
style: Theme.of(context).textTheme.bodyMedium,),
TextSpan(
text: "${S.of(context).zcash_network_upgrade_desc_2}\n\n",
style: Theme.of(context).textTheme.bodyMedium,),
TextSpan(
text: "${S.of(context).zcash_network_upgrade_desc_3}\n\n",
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: const Color(0xFFF9C03C),
),
),
],
),
),
],
),
),
],
),
),
),
Padding(
padding: const EdgeInsets.all(12),
child: Column(
spacing: 12,
children: [
NewPrimaryButton(
onPressed: _launchDocs,
text: S.of(context).learn_more,
color: Theme.of(context).colorScheme.surfaceContainer,
textColor: Theme.of(context).colorScheme.primary,),
if (hasContinue)
NewPrimaryButton(
onPressed: Navigator.of(context).pop,
text: S.of(context).continue_text,
color: Theme.of(context).colorScheme.primary,
textColor: Theme.of(context).colorScheme.onPrimary,),
],
),
),
],
),
),
),
);
void _launchDocs() {
try {
launchUrl(Uri.parse("https://github.com/Such-Software/hash-wallet"));
} catch (_) {}
}
}
+33 -2
View File
@@ -1,12 +1,23 @@
import 'dart:math';
import 'package:hash_wallet/new-ui/widgets/new_primary_button.dart';
import 'package:flutter/material.dart';
class ConfirmSwiper extends StatefulWidget {
final VoidCallback onConfirmed;
final String swiperText;
const ConfirmSwiper({super.key, required this.onConfirmed, required this.swiperText});
/// Label of the plain button rendered instead of the swiper when the platform
/// reports accessible navigation (VoiceOver / TalkBack), which intercepts the
/// horizontal drag the swiper depends on. Falls back to [swiperText].
final String? accessibleNavigationModeButtonText;
const ConfirmSwiper({
super.key,
required this.onConfirmed,
required this.swiperText,
this.accessibleNavigationModeButtonText,
});
@override
State<ConfirmSwiper> createState() => _ConfirmSwiperState();
@@ -30,6 +41,15 @@ class _ConfirmSwiperState extends State<ConfirmSwiper> {
@override
Widget build(BuildContext context) {
if (MediaQuery.of(context).accessibleNavigation) {
return NewPrimaryButton(
onPressed: widget.onConfirmed,
text: widget.accessibleNavigationModeButtonText ?? widget.swiperText,
color: Theme.of(context).colorScheme.primary,
textColor: Theme.of(context).colorScheme.onPrimary,
);
}
final radius = (pillSize + pillHorizontalPadding * 2) / 2;
return LayoutBuilder(
@@ -41,7 +61,7 @@ class _ConfirmSwiperState extends State<ConfirmSwiper> {
final maxDrag = areaWidth - pillSize - pillHorizontalPadding;
final triggerAt = maxDrag - _triggerThreshold;
return GestureDetector(
final swiper = GestureDetector(
onHorizontalDragUpdate: (d) {
setState(() {
drag = max(pillHorizontalPadding, min(maxDrag, drag + d.delta.dx));
@@ -98,6 +118,17 @@ class _ConfirmSwiperState extends State<ConfirmSwiper> {
),
),
);
// A single accessibility node for the whole control: the flowing label and
// the arrow knob are decorative and must not become separate stops. No
// semantics action is exposed here; a screen reader gets the button
// variant above instead.
return Semantics(
container: true,
excludeSemantics: true,
label: widget.swiperText,
child: swiper,
);
},
);
}
+60 -7
View File
@@ -1,21 +1,37 @@
import 'dart:io';
import 'package:hash_wallet/generated/i18n.dart';
import 'package:hash_wallet/utils/clipboard_util.dart';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:flutter/material.dart';
import 'package:flutter/semantics.dart';
import 'package:flutter/services.dart';
class CopyWrapper extends StatefulWidget {
const CopyWrapper(
{super.key,
this.data,
this.requireLongPress = false,
this.isSensitive = false,
required this.builder,
this.duration = const Duration(milliseconds: 1200)});
this.builder,
this.controlBuilder,
this.duration = const Duration(milliseconds: 1200)})
: assert((builder == null) != (controlBuilder == null),
"Provide exactly one of builder or controlBuilder");
final ClipboardData? data;
final bool isSensitive;
final Widget Function(BuildContext, bool) builder;
final bool requireLongPress;
/// Builds content that this widget makes copyable with its own gesture
/// detector. Receives the transient "copied" state.
final Widget Function(BuildContext, bool)? builder;
/// Builds a control that performs the copy itself, so no extra gesture
/// detector or actionable semantics node is stacked on top of it. [onCopy] is
/// null when there is nothing to copy.
final Widget Function(BuildContext context, bool copied, VoidCallback? onCopy)? controlBuilder;
final Duration duration;
@override
@@ -28,6 +44,12 @@ class _CopyWrapperState extends State<CopyWrapper> {
void handleCopy() async {
if (widget.data == null) return;
ClipboardUtil.setSensitiveDataToClipboard(widget.data!, isSensitive: widget.isSensitive);
HapticFeedback.mediumImpact();
// Screen readers learn about the copy from the transient "copied" state
// below, which build() marks as a live region, instead of from a push
// announcement: those are deprecated on Android and make TalkBack drop
// whatever it was saying. Where we don't render that state, android 13+
// already shows its own clipboard confirmation.
if (await shouldShowCopied()) {
setState(() => copied = true);
Future.delayed(widget.duration, () {
@@ -38,10 +60,41 @@ class _CopyWrapperState extends State<CopyWrapper> {
@override
Widget build(BuildContext context) {
return GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: handleCopy,
child: widget.builder(context, copied),
final VoidCallback? onCopy = widget.data == null ? null : handleCopy;
// The control's own label carries the copied state (Copy -> Copied), so
// merging it into one node and marking that node a live region while copied
// is enough for a screen reader to pick the change up.
if (widget.controlBuilder != null) {
return MergeSemantics(
child: Semantics(
liveRegion: copied,
child: widget.controlBuilder!(context, copied, onCopy),
),
);
}
final content = widget.builder!(context, copied);
// Nothing to copy: don't leave behind a gesture detector that a screen
// reader would present as an actionable target that does nothing.
if (onCopy == null) return content;
return MergeSemantics(
child: Semantics(
button: !widget.requireLongPress,
liveRegion: copied,
hint: widget.requireLongPress ? S.of(context).long_press_to_copy : S.of(context).copy,
customSemanticsActions: <CustomSemanticsAction, VoidCallback>{
CustomSemanticsAction(label: S.of(context).copy): onCopy,
},
child: GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: widget.requireLongPress ? null : onCopy,
onLongPress: !widget.requireLongPress ? null : onCopy,
child: content,
),
),
);
}
@@ -0,0 +1,90 @@
import 'package:hash_wallet/generated/i18n.dart';
import 'package:cw_core/wallet_type.dart';
import 'package:flutter/material.dart';
class ChainChipStrip extends StatelessWidget {
const ChainChipStrip({
super.key,
required this.walletTypes,
required this.selected,
required this.onSelected,
});
final List<WalletType> walletTypes;
final WalletType? selected;
final ValueChanged<WalletType?> onSelected;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.only(bottom: 4),
height: 48,
child: ListView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 16),
children: [
_ChainChip(
label: S.of(context).picker_chip_all,
isSelected: selected == null,
onTap: () => onSelected(null),
),
for (final type in walletTypes)
_ChainChip(
label: walletTypeToString(type),
isSelected: selected == type,
onTap: () => onSelected(type),
),
],
),
);
}
}
class _ChainChip extends StatelessWidget {
const _ChainChip({
required this.label,
required this.isSelected,
required this.onTap,
});
final String label;
final bool isSelected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
return Padding(
padding: const EdgeInsetsDirectional.only(end: 8),
child: MergeSemantics(
child: Semantics(
button: true,
selected: isSelected,
inMutuallyExclusiveGroup: true,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(80),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10),
decoration: BoxDecoration(
color: isSelected ? colors.primary : colors.surfaceContainer,
borderRadius: BorderRadius.circular(20),
),
child: Center(
child: Text(
label,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w500,
fontSize: isSelected ? 14 : 12,
letterSpacing: -0.07,
color: isSelected ? colors.onPrimary : colors.primary,
),
),
),
),
),
),
),
);
}
}
@@ -0,0 +1,142 @@
import "package:hash_wallet/evm/evm.dart";
import "package:cw_core/crypto_currency.dart";
import "package:cw_core/currency_for_wallet_type.dart";
import "package:cw_core/currency_groups.dart";
import "package:cw_core/erc20_token.dart";
import "package:cw_core/spl_token.dart";
import "package:cw_core/tron_token.dart";
import "package:cw_core/wallet_type.dart";
String chainNameForCurrency(CryptoCurrency c) {
if (c == CryptoCurrency.btcln) {
return "Lightning";
}
final wt = cryptoCurrencyOrTokenToWalletType(c);
return wt != null ? walletTypeToString(wt) : (c.tag ?? "");
}
final Set<String> _stablecoinSymbols = {
for (final c in CryptoCurrency.all)
if (c.groups.contains(CurrencyGroups.stablecoin)) c.title.toUpperCase(),
};
bool isTrustedStablecoin(CryptoCurrency c) =>
(c.groups.contains(CurrencyGroups.stablecoin) ||
_stablecoinSymbols.contains(c.title.toUpperCase())) &&
!c.isPotentialScam;
bool hasBuiltInStablecoinSymbol(CryptoCurrency c) =>
_stablecoinSymbols.contains(c.title.toUpperCase());
const _kEvmDefaultTokenNatives = <CryptoCurrency>[
CryptoCurrency.baseEth,
CryptoCurrency.arbEth,
CryptoCurrency.maticpoly,
];
void appendEvmDefaultTokens(List<CryptoCurrency> into) {
if (evm == null) {
return;
}
String keyFor(CryptoCurrency c) {
final wt = cryptoCurrencyOrTokenToWalletType(c);
final chain = wt?.toString() ?? (c.tag ?? "").toUpperCase();
return "${c.title.toUpperCase()}|$chain";
}
final seen = <String>{for (final c in into) keyFor(c)};
for (final native in _kEvmDefaultTokenNatives) {
final chainId = getChainIdByCryptoCurrency(native);
if (chainId == null) {
continue;
}
for (final t in evm!.getDefaultTokensByChainId(chainId)) {
if (seen.add(keyFor(t))) {
into.add(t);
}
}
}
}
class CurrencyPickerBalance {
const CurrencyPickerBalance({required this.amount, this.fiat, this.fiatValue});
final String amount;
final String? fiat;
final double? fiatValue;
}
String? _assetAddressKey(CryptoCurrency c) {
if (c is Erc20Token) {
return c.contractAddress.toLowerCase();
}
if (c is TronToken) {
return c.contractAddress.toLowerCase();
}
if (c is SPLToken) {
return c.mintAddress.toLowerCase();
}
return null;
}
CurrencyPickerBalance? balanceForAsset(
Map<CryptoCurrency, CurrencyPickerBalance>? balances,
CryptoCurrency asset,
) {
if (balances == null || balances.isEmpty) {
return null;
}
final direct = balances[asset];
if (direct != null) {
return direct;
}
final addressKey = _assetAddressKey(asset);
if (addressKey != null) {
for (final entry in balances.entries) {
if (_assetAddressKey(entry.key) == addressKey) {
return entry.value;
}
}
return null;
}
final title = asset.title.toUpperCase();
CurrencyPickerBalance? byTitle;
for (final entry in balances.entries) {
if (entry.key.title.toUpperCase() == title) {
if (byTitle != null) {
return null;
}
byTitle = entry.value;
}
}
return byTitle;
}
enum RecentsSource { none, trades, orders }
class CurrencyPickerArgs {
const CurrencyPickerArgs({
this.selected,
required this.items,
this.showStablesHeader = true,
this.balanceByAsset,
this.filterByNetwork,
required this.onSelected,
required this.symbolResolver,
this.recentsSource = RecentsSource.none,
this.useSingleNetworkLayout = false,
});
final CryptoCurrency? selected;
final List<CryptoCurrency> items;
final WalletType? filterByNetwork;
final bool showStablesHeader;
final void Function(CryptoCurrency) onSelected;
final String Function(CryptoCurrency) symbolResolver;
final Map<CryptoCurrency, CurrencyPickerBalance>? balanceByAsset;
final RecentsSource recentsSource;
final bool useSingleNetworkLayout;
}
@@ -0,0 +1,32 @@
import 'package:flutter/material.dart';
class CurrencyPickerListContainer extends StatelessWidget {
const CurrencyPickerListContainer({super.key, required this.rows});
final List<Widget> rows;
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
final children = <Widget>[];
for (var i = 0; i < rows.length; i++) {
children.add(rows[i]);
if (i != rows.length - 1) {
children.add(Divider(
height: 1,
thickness: 1,
color: colors.surfaceContainerHigh,
indent: 56,
endIndent: 24,
));
}
}
return Container(
decoration: BoxDecoration(
color: colors.surfaceContainer,
borderRadius: BorderRadius.circular(18),
),
child: Column(mainAxisSize: MainAxisSize.min, children: children),
);
}
}
@@ -0,0 +1,149 @@
import 'package:hash_wallet/new-ui/widgets/coins_page/token_image_widget.dart';
import 'package:hash_wallet/src/widgets/hash_image_widget.dart';
import 'package:cw_core/crypto_currency.dart';
import 'package:flutter/material.dart';
class CurrencyPickerRow extends StatelessWidget {
const CurrencyPickerRow({
super.key,
required this.currency,
required this.isSelected,
required this.trailing,
required this.onTap,
this.chainPillLabel,
this.chainBadgePath,
});
final CryptoCurrency currency;
final bool isSelected;
final Widget trailing;
final VoidCallback onTap;
final String? chainPillLabel;
final String? chainBadgePath;
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
return MergeSemantics(
child: Semantics(
button: true,
selected: isSelected,
child: InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
child: Row(
children: [
ExcludeSemantics(
child: _IconWithBadge(currency: currency, badgePath: chainBadgePath),
),
const SizedBox(width: 12),
Expanded(
child: Row(
children: [
Flexible(
child: Text(
currency.fullName ?? currency.title,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w500,
),
),
),
if (chainPillLabel != null) ...[
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: colors.surfaceContainerHigh,
borderRadius: BorderRadius.circular(10),
),
child: Text(
chainPillLabel!,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
fontWeight: FontWeight.w500,
color: colors.onSecondaryContainer,
),
),
),
],
],
),
),
const SizedBox(width: 8),
trailing,
const SizedBox(width: 8),
ExcludeSemantics(
child: Icon(
Icons.chevron_right,
size: 20,
color: isSelected ? colors.primary : colors.onSurfaceVariant,
),
),
],
),
),
),
),
);
}
}
class _IconWithBadge extends StatelessWidget {
const _IconWithBadge({required this.currency, required this.badgePath});
final CryptoCurrency currency;
final String? badgePath;
@override
Widget build(BuildContext context) {
final icon = TokenImageWidget(
imageUrl: currency.iconPath ?? '',
size: 32,
errorWidget: Container(
width: 32,
height: 32,
child: Center(
child: Text(
currency.title.length >= 2 ? currency.title.substring(0, 2) : currency.title,
),
),
),
);
if (badgePath == null) return icon;
return SizedBox(
width: 36,
height: 36,
child: Stack(
clipBehavior: Clip.none,
children: [
icon,
Positioned(
right: -2,
bottom: -2,
child: Container(
width: 18,
height: 18,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(6),
border: Border.all(
color: Theme.of(context).colorScheme.surfaceContainerHigh,
width: 2,
),
color: Theme.of(context).colorScheme.onSurface,
),
padding: const EdgeInsets.all(2),
child: HashImageWidget(
imageUrl: badgePath,
color: Theme.of(context).colorScheme.surface,
width: 13,
height: 13,
fit: BoxFit.cover,
),
),
),
],
),
);
}
}
@@ -0,0 +1,84 @@
import 'package:hash_wallet/generated/i18n.dart';
import 'package:hash_wallet/new-ui/widgets/currency_picker/currency_picker_args.dart';
import 'package:cw_core/crypto_currency.dart';
import 'package:flutter/material.dart';
class CurrencyPickerSearchField extends StatelessWidget {
const CurrencyPickerSearchField({
super.key,
required this.controller,
required this.hintText,
});
final TextEditingController controller;
final String hintText;
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Container(
height: 48,
padding: const EdgeInsets.symmetric(horizontal: 14),
decoration: BoxDecoration(
color: colors.surfaceContainer,
border: Border.all(color: colors.surfaceContainer, width: 1),
borderRadius: BorderRadius.circular(28),
),
child: Row(
children: [
ExcludeSemantics(child: Icon(Icons.search, size: 20, color: colors.primary)),
const SizedBox(width: 10),
Expanded(
// The hint names the field only while it is empty, so the label is
// supplied once there is text to keep exactly one announcement.
child: MergeSemantics(
child: Semantics(
label: controller.text.isEmpty ? null : hintText,
child: TextFormField(
controller: controller,
style: Theme.of(context).textTheme.bodyMedium,
decoration: InputDecoration(
isCollapsed: true,
border: InputBorder.none,
focusedBorder: InputBorder.none,
hintText: hintText,
hintStyle: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colors.onSurfaceVariant,
),
),
),
),
),
),
if (controller.text.isNotEmpty)
Semantics(
button: true,
label: S.of(context).clear,
child: InkWell(
onTap: controller.clear,
customBorder: const CircleBorder(),
child: Padding(
padding: const EdgeInsets.all(4),
child: Icon(Icons.close, size: 16, color: colors.onSurfaceVariant),
),
),
),
],
),
),
);
}
}
bool currencyMatchesQuery(CryptoCurrency c, String query) {
if (query.isEmpty) return true;
final q = query.toLowerCase();
if (c.title.toLowerCase().contains(q)) return true;
if (c.name.toLowerCase().contains(q)) return true;
if (c.fullName?.toLowerCase().contains(q) ?? false) return true;
if (c.tag?.toLowerCase().contains(q) ?? false) return true;
if (chainNameForCurrency(c).toLowerCase().contains(q)) return true;
return false;
}
@@ -0,0 +1,60 @@
import 'package:hash_wallet/generated/i18n.dart';
import 'package:hash_wallet/new-ui/widgets/currency_picker/currency_picker_args.dart';
import 'package:hash_wallet/new-ui/widgets/currency_picker/multi_network_currency_picker.dart';
import 'package:hash_wallet/new-ui/widgets/currency_picker/single_network_currency_picker.dart';
import 'package:hash_wallet/new-ui/widgets/receive_page/receive_top_bar.dart';
import 'package:flutter/material.dart';
class CurrencyPickerSheet extends StatelessWidget {
const CurrencyPickerSheet({super.key, required this.args});
final CurrencyPickerArgs args;
static Future<void> show({
required BuildContext context,
required CurrencyPickerArgs args,
}) {
return showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
useSafeArea: true,
enableDrag: false,
isDismissible: false,
backgroundColor: Colors.transparent,
builder: (_) => CurrencyPickerSheet(args: args),
);
}
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
return Padding(
padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
child: Container(
decoration: BoxDecoration(
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
color: colors.surface,
),
child: SafeArea(
top: false,
child: Column(
mainAxisSize: MainAxisSize.max,
children: [
ModalTopBar(
title: S.of(context).select_asset,
leadingIcon: const Icon(Icons.close),
leadingSemanticLabel: S.of(context).close,
onLeadingPressed: () => Navigator.of(context).maybePop(),
),
Expanded(
child: args.useSingleNetworkLayout
? SingleNetworkCurrencyPicker(args: args)
: MultiNetworkCurrencyPicker(args: args),
),
],
),
),
),
);
}
}
@@ -0,0 +1,315 @@
import 'package:hash_wallet/entities/fiat_currency.dart';
import 'package:hash_wallet/generated/i18n.dart';
import 'package:hash_wallet/new-ui/widgets/currency_picker/currency_picker_list_container.dart';
import 'package:hash_wallet/new-ui/widgets/currency_picker/currency_picker_search_field.dart';
import 'package:hash_wallet/new-ui/widgets/currency_picker/fiat_currency_row.dart';
import 'package:hash_wallet/new-ui/widgets/currency_picker/fiat_currency_search_result.dart';
import 'package:hash_wallet/new-ui/widgets/receive_page/receive_top_bar.dart';
import 'package:hash_wallet/src/widgets/hash_image_widget.dart';
import 'package:cw_core/crypto_currency.dart';
import 'package:flutter/material.dart';
class FiatCurrencyPickerSheet extends StatefulWidget {
const FiatCurrencyPickerSheet({
super.key,
required this.selected,
required this.onSelected,
this.cryptoOption,
this.onCryptoSelected,
});
final Object? selected;
final ValueChanged<FiatCurrency> onSelected;
final CryptoCurrency? cryptoOption;
final ValueChanged<CryptoCurrency>? onCryptoSelected;
static const _popularOrder = <FiatCurrency>[
FiatCurrency.usd,
FiatCurrency.eur,
FiatCurrency.gbp,
FiatCurrency.jpy,
FiatCurrency.cny,
];
static Future<void> show({
required BuildContext context,
required Object? selected,
required ValueChanged<FiatCurrency> onSelected,
CryptoCurrency? cryptoOption,
ValueChanged<CryptoCurrency>? onCryptoSelected,
}) {
return showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
useSafeArea: true,
enableDrag: false,
backgroundColor: Colors.transparent,
builder: (_) => FiatCurrencyPickerSheet(
selected: selected,
onSelected: onSelected,
cryptoOption: cryptoOption,
onCryptoSelected: onCryptoSelected,
),
);
}
@override
State<FiatCurrencyPickerSheet> createState() => _FiatCurrencyPickerSheetState();
}
class _FiatCurrencyPickerSheetState extends State<FiatCurrencyPickerSheet> {
final TextEditingController _searchController = TextEditingController();
late final List<FiatCurrency> _allSorted = [...FiatCurrency.all]..sort(
(a, b) => a.fullName.toLowerCase().compareTo(b.fullName.toLowerCase()),
);
bool get _isSearching => _searchController.text.trim().isNotEmpty;
@override
void initState() {
super.initState();
_searchController.addListener(() => setState(() {}));
}
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
bool _matches(FiatCurrency c, String query) {
if (query.isEmpty) return true;
final q = query.toLowerCase();
return c.fullName.toLowerCase().contains(q) || c.title.toLowerCase().contains(q);
}
void _selectFiatCurrency(FiatCurrency c) {
widget.onSelected(c);
Navigator.of(context).maybePop();
}
void _selectCryptoCurrency(CryptoCurrency c) {
widget.onCryptoSelected?.call(c);
Navigator.of(context).maybePop();
}
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
final selected = widget.selected;
final selectedFiat = selected is FiatCurrency ? selected : null;
final popular = FiatCurrencyPickerSheet._popularOrder
.where((c) => c != selectedFiat)
.toList(growable: false);
final query = _searchController.text.trim();
final filteredAll = _allSorted.where((c) => _matches(c, query)).toList(growable: false);
return Padding(
padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
child: Container(
decoration: BoxDecoration(
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
color: colors.surface,
),
child: Column(
mainAxisSize: MainAxisSize.max,
children: [
ModalTopBar(
title: S.of(context).select_fiat_currency_title,
leadingIcon: const Icon(Icons.close),
leadingSemanticLabel: S.of(context).close,
onLeadingPressed: () => Navigator.of(context).maybePop(),
),
Expanded(
child: _isSearching
? FiatCurrencySearchResults(
items: filteredAll,
onSelected: _selectFiatCurrency,
selected: selectedFiat,
)
: FiatCurrencyPickerBody(
selected: selected,
cryptoOption: widget.cryptoOption,
popular: popular,
all: filteredAll,
onSelectedFiat: _selectFiatCurrency,
onSelectedCrypto: _selectCryptoCurrency,
),
),
CurrencyPickerSearchField(
controller: _searchController,
hintText: S.of(context).search,
),
const SizedBox(height: 24),
],
),
),
);
}
}
class FiatCurrencyPickerBody extends StatelessWidget {
const FiatCurrencyPickerBody({
super.key,
required this.selected,
required this.cryptoOption,
required this.popular,
required this.all,
required this.onSelectedFiat,
required this.onSelectedCrypto,
});
final Object? selected;
final CryptoCurrency? cryptoOption;
final List<FiatCurrency> popular;
final List<FiatCurrency> all;
final void Function(FiatCurrency) onSelectedFiat;
final void Function(CryptoCurrency) onSelectedCrypto;
@override
Widget build(BuildContext context) {
final selectedFiat = selected is FiatCurrency ? selected as FiatCurrency : null;
final selectedCrypto = selected is CryptoCurrency ? selected as CryptoCurrency : null;
final showCryptoSection = cryptoOption != null && selectedCrypto == null;
return ListView(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
children: [
if (selectedFiat != null || selectedCrypto != null) ...[
_SectionHeader(label: S.of(context).picker_section_selected),
CurrencyPickerListContainer(
rows: [
if (selectedFiat != null)
FiatCurrencyRow(
currency: selectedFiat,
isSelected: true,
onTap: () => onSelectedFiat(selectedFiat),
),
if (selectedCrypto != null)
_CryptoOptionRow(
currency: selectedCrypto,
isSelected: true,
onTap: () => onSelectedCrypto(selectedCrypto),
),
],
),
const SizedBox(height: 16),
],
if (showCryptoSection) ...[
_SectionHeader(label: S.of(context).picker_section_crypto),
CurrencyPickerListContainer(
rows: [
_CryptoOptionRow(
currency: cryptoOption!,
isSelected: false,
onTap: () => onSelectedCrypto(cryptoOption!),
),
],
),
const SizedBox(height: 16),
],
if (popular.isNotEmpty) ...[
_SectionHeader(label: S.of(context).picker_section_popular),
CurrencyPickerListContainer(
rows: [
for (final c in popular)
FiatCurrencyRow(
currency: c,
isSelected: false,
onTap: () => onSelectedFiat(c),
),
],
),
const SizedBox(height: 16),
],
_SectionHeader(label: S.of(context).picker_section_all_currencies),
CurrencyPickerListContainer(
rows: [
for (final c in all)
FiatCurrencyRow(
currency: c,
isSelected: c == selectedFiat,
onTap: () => onSelectedFiat(c),
),
],
),
],
);
}
}
class _SectionHeader extends StatelessWidget {
const _SectionHeader({required this.label});
final String label;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8),
child: Text(
label,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w500,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
);
}
}
class _CryptoOptionRow extends StatelessWidget {
const _CryptoOptionRow({
required this.currency,
required this.isSelected,
required this.onTap,
});
final CryptoCurrency currency;
final bool isSelected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
return InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
child: Row(
children: [
HashImageWidget(
imageUrl: currency.iconPath,
width: 28,
height: 28,
fit: BoxFit.cover,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
currency.fullName ?? currency.title,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 2),
Text(
currency.title,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colors.onSurfaceVariant,
),
),
],
),
),
if (isSelected) Icon(Icons.check, size: 20, color: colors.primary),
],
),
),
);
}
}
@@ -0,0 +1,67 @@
import 'package:hash_wallet/entities/fiat_currency.dart';
import 'package:hash_wallet/src/widgets/hash_image_widget.dart';
import 'package:flutter/material.dart';
class FiatCurrencyRow extends StatelessWidget {
const FiatCurrencyRow({
required this.currency,
required this.isSelected,
required this.onTap,
});
final FiatCurrency currency;
final bool isSelected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
return InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
child: Row(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: HashImageWidget(
imageUrl: 'assets/images/flags/${currency.countryCode}.png',
width: 28,
height: 20,
fit: BoxFit.cover,
errorWidget: Container(
width: 28,
height: 20,
color: colors.surfaceContainer,
),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
currency.fullName,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 2),
Text(
currency.title,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colors.onSurfaceVariant,
),
),
],
),
),
if (isSelected) Icon(Icons.check, size: 20, color: colors.primary),
],
),
),
);
}
}
@@ -0,0 +1,52 @@
import 'package:hash_wallet/entities/fiat_currency.dart';
import 'package:hash_wallet/generated/i18n.dart';
import 'package:hash_wallet/new-ui/widgets/currency_picker/currency_picker_list_container.dart';
import 'package:hash_wallet/new-ui/widgets/currency_picker/fiat_currency_row.dart';
import 'package:flutter/material.dart';
class FiatCurrencySearchResults extends StatelessWidget {
const FiatCurrencySearchResults({
super.key,
required this.items,
required this.onSelected,
required this.selected,
});
final List<FiatCurrency> items;
final void Function(FiatCurrency) onSelected;
final FiatCurrency? selected;
@override
Widget build(BuildContext context) {
if (items.isEmpty) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Text(
S.of(context).picker_no_matches,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
),
);
}
return ListView(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
children: [
CurrencyPickerListContainer(
rows: [
for (final c in items)
FiatCurrencyRow(
currency: c,
isSelected: c == selected,
onTap: () => onSelected(c),
),
],
),
],
);
}
}
@@ -0,0 +1,686 @@
import 'package:hash_wallet/generated/i18n.dart';
import 'package:hash_wallet/new-ui/widgets/coins_page/token_image_widget.dart';
import 'package:hash_wallet/new-ui/widgets/currency_picker/chain_chip_strip.dart';
import 'package:hash_wallet/new-ui/widgets/currency_picker/currency_picker_args.dart';
import 'package:hash_wallet/new-ui/widgets/currency_picker/picker_recents_loader.dart';
import 'package:hash_wallet/new-ui/widgets/currency_picker/currency_picker_list_container.dart';
import 'package:hash_wallet/new-ui/widgets/currency_picker/currency_picker_row.dart';
import 'package:hash_wallet/new-ui/widgets/currency_picker/currency_picker_search_field.dart';
import 'package:hash_wallet/new-ui/widgets/currency_picker/picker_section_header.dart';
import 'package:hash_wallet/new-ui/widgets/currency_picker/pill_grid.dart';
import 'package:hash_wallet/new-ui/widgets/currency_picker/select_network_page.dart';
import 'package:hash_wallet/wallet_types.g.dart';
import 'package:cw_core/crypto_currency.dart';
import 'package:cw_core/currency_for_wallet_type.dart';
import 'package:cw_core/currency_groups.dart';
import 'package:cw_core/utils/print_verbose.dart';
import 'package:cw_core/wallet_type.dart';
import 'package:flutter/material.dart';
class MultiNetworkCurrencyPicker extends StatefulWidget {
const MultiNetworkCurrencyPicker({super.key, required this.args});
final CurrencyPickerArgs args;
@override
State<MultiNetworkCurrencyPicker> createState() => _MultiNetworkCurrencyPickerState();
}
class _MultiNetworkCurrencyPickerState extends State<MultiNetworkCurrencyPicker> {
bool _recentsLoaded = false;
WalletType? _selectedNetwork;
List<CryptoCurrency> _recents = const [];
final TextEditingController _searchController = TextEditingController();
bool get _isSearching => _searchController.text.trim().isNotEmpty;
late final List<WalletType> _networks = _computeNetworks();
List<WalletType> _computeNetworks() {
final counts = <WalletType, int>{};
for (final currency in widget.args.items) {
final walletType = cryptoCurrencyOrTokenToWalletType(currency);
if (walletType == null) continue;
counts[walletType] = (counts[walletType] ?? 0) + 1;
}
return counts.entries.where((e) => e.value >= 2).map((e) => e.key).toList(growable: false);
}
Set<CryptoCurrency> get _natives =>
{for (final walletType in availableWalletTypes) walletTypeToCryptoCurrency(walletType)};
@override
void initState() {
super.initState();
_searchController.addListener(() => setState(() {}));
_loadRecents();
}
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
Future<void> _loadRecents() async {
try {
final recents = await PickerRecentsLoader.load(
source: widget.args.recentsSource,
visibleItems: widget.args.items,
);
if (!mounted) return;
setState(() {
_recents = recents;
_recentsLoaded = true;
});
} catch (e, s) {
printV('load picker recents failed: $e\n$s');
if (!mounted) return;
setState(() => _recentsLoaded = true);
}
}
bool _matchesNetwork(CryptoCurrency c) {
if (_selectedNetwork == null) return true;
return cryptoCurrencyOrTokenToWalletType(c) == _selectedNetwork;
}
List<CryptoCurrency> get _visibleItems {
final query = _searchController.text.trim();
return widget.args.items
.where(_hasSupportedChain)
.where((c) => currencyMatchesQuery(c, query) && _matchesNetwork(c))
.toList(growable: false);
}
bool _hasSupportedChain(CryptoCurrency c) {
if (c.tag == null) return true;
return cryptoCurrencyOrTokenToWalletType(c) != null;
}
void _selectCurrency(CryptoCurrency currency) {
widget.args.onSelected(currency);
Navigator.of(context).maybePop();
}
void _onStablecoinPillTapped(CryptoCurrency tapped) {
var variants = widget.args.items
.where((c) =>
isTrustedStablecoin(c) &&
c.title.toUpperCase() == tapped.title.toUpperCase() &&
cryptoCurrencyOrTokenToWalletType(c) != null)
.toList();
if (_selectedNetwork != null) {
final filtered = variants
.where((c) => cryptoCurrencyOrTokenToWalletType(c) == _selectedNetwork)
.toList(growable: false);
if (filtered.isNotEmpty) variants = filtered;
}
if (variants.length <= 1) {
_selectCurrency(variants.isNotEmpty ? variants.first : tapped);
return;
}
Navigator.of(context).push(
PageRouteBuilder<void>(
opaque: true,
transitionDuration: const Duration(milliseconds: 250),
reverseTransitionDuration: const Duration(milliseconds: 250),
pageBuilder: (_, __, ___) => SelectNetworkPage(
assetTitle: tapped.title,
assetFullName: tapped.fullName,
assetIconPath: tapped.iconPath,
variants: variants,
onSelected: widget.args.onSelected,
),
transitionsBuilder: (_, animation, __, child) => SlideTransition(
position: animation.drive(
Tween<Offset>(begin: const Offset(0, 1), end: Offset.zero)
.chain(CurveTween(curve: Curves.easeInOut)),
),
child: child,
),
),
);
}
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.max,
children: [
if (_networks.length > 1)
ChainChipStrip(
walletTypes: _networks,
selected: _selectedNetwork,
onSelected: (network) => setState(() => _selectedNetwork = network),
),
Expanded(
child: _MultiNetworkPickerBody(
items: _visibleItems,
isSearching: _isSearching,
recents: _recents,
recentsLoaded: _recentsLoaded,
natives: _natives,
selected: widget.args.selected,
symbolResolver: widget.args.symbolResolver,
showStablesHeader: widget.args.showStablesHeader,
onSelect: _selectCurrency,
onStablecoinTap: _onStablecoinPillTapped,
),
),
CurrencyPickerSearchField(
controller: _searchController,
hintText: S.of(context).search,
),
],
);
}
}
class _MultiNetworkPickerBody extends StatefulWidget {
const _MultiNetworkPickerBody({
required this.items,
required this.isSearching,
required this.recents,
required this.recentsLoaded,
required this.natives,
required this.selected,
required this.symbolResolver,
required this.onSelect,
required this.onStablecoinTap,
required this.showStablesHeader,
});
final bool isSearching;
final bool recentsLoaded;
final bool showStablesHeader;
final CryptoCurrency? selected;
final List<CryptoCurrency> items;
final List<CryptoCurrency> recents;
final Set<CryptoCurrency> natives;
final void Function(CryptoCurrency) onSelect;
final String Function(CryptoCurrency) symbolResolver;
final void Function(CryptoCurrency) onStablecoinTap;
@override
State<_MultiNetworkPickerBody> createState() => _MultiNetworkPickerBodyState();
}
class _MultiNetworkPickerBodyState extends State<_MultiNetworkPickerBody> {
static const int _previewCount = 3;
final ScrollController _scrollController = ScrollController();
bool _moreCryptosSectionExpanded = false;
bool _xstocksSectionExpanded = false;
@override
void initState() {
super.initState();
final selected = widget.selected;
if (selected == null) return;
final moreCryptos = _computeMoreCryptosSection(widget.items);
final xstocks = _computeXstocksSection(widget.items);
if (moreCryptos.indexOf(selected) >= _previewCount) {
_moreCryptosSectionExpanded = true;
}
if (xstocks.indexOf(selected) >= _previewCount) {
_xstocksSectionExpanded = true;
}
}
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
List<CryptoCurrency> _computeMoreCryptosSection(List<CryptoCurrency> from) {
final list = from
.where((c) =>
!widget.natives.contains(c) &&
cryptoCurrencyOrTokenToWalletType(c) == null &&
c.tag == null &&
!c.groups.contains(CurrencyGroups.stablecoin) &&
!c.groups.contains(CurrencyGroups.tokenizedStock))
.toList();
list.sort((a, b) => (a.fullName ?? a.title).toLowerCase().compareTo(
(b.fullName ?? b.title).toLowerCase(),
));
return list;
}
List<CryptoCurrency> _computeXstocksSection(List<CryptoCurrency> from) {
return from
.where((c) => c.groups.contains(CurrencyGroups.tokenizedStock))
.toList(growable: false);
}
@override
Widget build(BuildContext context) {
final items = widget.items;
final isSearching = widget.isSearching;
final recents = widget.recents;
final recentsLoaded = widget.recentsLoaded;
final natives = widget.natives;
final selected = widget.selected;
final symbolResolver = widget.symbolResolver;
final onSelect = widget.onSelect;
final onStablecoinTap = widget.onStablecoinTap;
if (items.isEmpty) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Text(
S.of(context).picker_no_matches,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
),
);
}
if (isSearching) {
return NotificationListener<ScrollNotification>(
onNotification: (_) => true,
child: ListView(
controller: _scrollController,
primary: false,
physics: const ClampingScrollPhysics(),
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
children: [
CurrencyPickerListContainer(
rows: [
for (final item in items)
CurrencyPickerRow(
currency: item,
isSelected: selected != null && selected == item,
chainPillLabel: _chainPillLabelFor(item),
chainBadgePath: _chainBadgePathFor(item),
trailing: _SymbolTrailing(
currency: item,
symbolResolver: symbolResolver,
),
onTap: () => onSelect(item),
),
],
),
],
),
);
}
final visibleRecents = recents.where(items.contains).toList(growable: false);
final isSingleNetwork = items.map(cryptoCurrencyOrTokenToWalletType).toSet().length == 1;
final seenStablecoinTitles = <String>{};
final stablecoins = items
.where((c) =>
isTrustedStablecoin(c) &&
(isSingleNetwork || hasBuiltInStablecoinSymbol(c)) &&
seenStablecoinTitles.add(c.title.toUpperCase()))
.toList(growable: false);
final cryptocurrencies = items.where(natives.contains).toList(growable: false);
final moreCryptos = _computeMoreCryptosSection(items);
final xstocks = _computeXstocksSection(items);
final allAssets = [...items]..sort((a, b) => (a.fullName ?? a.title).toLowerCase().compareTo(
(b.fullName ?? b.title).toLowerCase(),
));
final section = _selectedSection(
recents: visibleRecents,
stablecoins: stablecoins,
cryptocurrencies: cryptocurrencies,
moreCryptos: moreCryptos,
xstocks: xstocks,
);
final moreCryptosVisible = _moreCryptosSectionExpanded
? moreCryptos
: moreCryptos.take(_previewCount).toList(growable: false);
final xstocksVisible =
_xstocksSectionExpanded ? xstocks : xstocks.take(_previewCount).toList(growable: false);
return NotificationListener<ScrollNotification>(
onNotification: (_) => true,
child: ListView(
controller: _scrollController,
primary: false,
physics: const ClampingScrollPhysics(),
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
children: [
if (recentsLoaded && visibleRecents.isNotEmpty)
_PickerSection(
title: S.of(context).picker_section_recents,
child: _RecentsRow(
items: visibleRecents,
selected: section == _SelSection.recents ? selected : null,
symbolResolver: symbolResolver,
onTap: onSelect,
),
),
if (stablecoins.isNotEmpty && widget.showStablesHeader)
_PickerSection(
title: S.of(context).picker_section_stablecoins,
child: PillGrid(
items: stablecoins,
selected: section == _SelSection.stablecoins ? selected : null,
onTap: onStablecoinTap,
symbolResolver: symbolResolver,
),
),
if (cryptocurrencies.isNotEmpty)
_PickerSection(
title: S.of(context).picker_section_cryptocurrencies,
child: CurrencyPickerListContainer(
rows: [
for (final item in cryptocurrencies)
CurrencyPickerRow(
currency: item,
isSelected: section == _SelSection.cryptocurrencies && selected == item,
chainPillLabel: _chainPillLabelFor(item),
chainBadgePath: _chainBadgePathFor(item),
trailing: _SymbolTrailing(
currency: item,
symbolResolver: symbolResolver,
),
onTap: () => onSelect(item),
),
],
),
),
if (moreCryptos.isNotEmpty)
_PickerSection(
title: S.of(context).picker_section_more_cryptocurrencies,
child: CurrencyPickerListContainer(
rows: [
for (final item in moreCryptosVisible)
CurrencyPickerRow(
currency: item,
isSelected: section == _SelSection.moreCryptocurrencies && selected == item,
trailing: _SymbolTrailing(
currency: item,
symbolResolver: symbolResolver,
),
onTap: () => onSelect(item),
),
if (moreCryptos.length > _previewCount)
_SeeAllRow(
expanded: _moreCryptosSectionExpanded,
onTap: () => setState(
() => _moreCryptosSectionExpanded = !_moreCryptosSectionExpanded),
),
],
),
),
if (xstocks.isNotEmpty)
_PickerSection(
title: S.of(context).picker_section_tokenized_stocks,
child: CurrencyPickerListContainer(
rows: [
for (final item in xstocksVisible)
CurrencyPickerRow(
currency: item,
isSelected: section == _SelSection.xstocks && selected == item,
trailing: _SymbolTrailing(
currency: item,
symbolResolver: symbolResolver,
),
onTap: () => onSelect(item),
chainPillLabel: _chainPillLabelFor(item),
chainBadgePath: _chainBadgePathFor(item),
),
if (xstocks.length > _previewCount)
_SeeAllRow(
expanded: _xstocksSectionExpanded,
onTap: () =>
setState(() => _xstocksSectionExpanded = !_xstocksSectionExpanded),
),
],
),
),
if (allAssets.isNotEmpty)
_PickerSection(
title: S.of(context).picker_section_all_assets,
child: CurrencyPickerListContainer(
rows: [
for (final item in allAssets)
CurrencyPickerRow(
currency: item,
isSelected: section == _SelSection.allAssets && selected == item,
chainPillLabel: _chainPillLabelFor(item),
chainBadgePath: _chainBadgePathFor(item),
trailing: _SymbolTrailing(
currency: item,
symbolResolver: symbolResolver,
),
onTap: () => onSelect(item),
),
],
),
),
],
),
);
}
String? _chainPillLabelFor(CryptoCurrency c) {
if (c == CryptoCurrency.btcln) return _shortChainLabel(c);
if (_isL2NativeEth(c)) return _shortChainLabel(c);
if (widget.natives.contains(c)) return null;
if (cryptoCurrencyOrTokenToWalletType(c) == null) return null;
return _shortChainLabel(c);
}
String _shortChainLabel(CryptoCurrency c) {
if (cryptoCurrencyOrTokenToWalletType(c) == WalletType.bsc) return 'BSC';
return chainNameForCurrency(c);
}
String? _chainBadgePathFor(CryptoCurrency c) {
if (_isL2NativeEth(c)) return c.chainIconPath;
if (widget.natives.contains(c)) return null;
final wt = cryptoCurrencyOrTokenToWalletType(c);
if (wt == null) return null;
return c.chainIconPath ?? walletTypeToCryptoCurrency(wt).chainIconPath;
}
bool _isL2NativeEth(CryptoCurrency c) =>
c == CryptoCurrency.arbEth || c == CryptoCurrency.baseEth || c == CryptoCurrency.robEth;
_SelSection? _selectedSection({
required List<CryptoCurrency> recents,
required List<CryptoCurrency> stablecoins,
required List<CryptoCurrency> cryptocurrencies,
required List<CryptoCurrency> moreCryptos,
required List<CryptoCurrency> xstocks,
}) {
final s = widget.selected;
if (s == null) return null;
if (recents.contains(s)) return _SelSection.recents;
if (stablecoins.any((c) => c.title.toUpperCase() == s.title.toUpperCase())) {
return _SelSection.stablecoins;
}
if (cryptocurrencies.contains(s)) return _SelSection.cryptocurrencies;
if (moreCryptos.contains(s)) return _SelSection.moreCryptocurrencies;
if (xstocks.contains(s)) return _SelSection.xstocks;
return _SelSection.allAssets;
}
}
class _PickerSection extends StatelessWidget {
const _PickerSection({required this.title, required this.child});
final String title;
final Widget child;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
PickerSectionHeader(title: title),
child,
const SizedBox(height: 16),
],
);
}
}
class _SymbolTrailing extends StatelessWidget {
const _SymbolTrailing({
required this.currency,
required this.symbolResolver,
});
final CryptoCurrency currency;
final String Function(CryptoCurrency) symbolResolver;
@override
Widget build(BuildContext context) {
return Text(
symbolResolver(currency),
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w500,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
);
}
}
class _SeeAllRow extends StatelessWidget {
const _SeeAllRow({
required this.expanded,
required this.onTap,
});
final bool expanded;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
final label = expanded ? S.of(context).picker_show_less : S.of(context).picker_see_all;
return InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14),
child: Row(
children: [
Expanded(
child: Text(
label,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w500,
color: colors.primary,
),
),
),
],
),
),
);
}
}
class _RecentsRow extends StatelessWidget {
const _RecentsRow({
required this.items,
required this.selected,
required this.symbolResolver,
required this.onTap,
});
final List<CryptoCurrency> items;
final CryptoCurrency? selected;
final String Function(CryptoCurrency) symbolResolver;
final void Function(CryptoCurrency) onTap;
@override
Widget build(BuildContext context) {
return SizedBox(
height: 44,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: items.length,
separatorBuilder: (_, __) => const SizedBox(width: 8),
itemBuilder: (_, i) {
final item = items[i];
return _RecentPill(
currency: item,
label: symbolResolver(item),
isSelected: selected != null && selected == item,
onTap: () => onTap(item),
);
},
),
);
}
}
class _RecentPill extends StatelessWidget {
const _RecentPill({
required this.currency,
required this.label,
required this.isSelected,
required this.onTap,
});
final CryptoCurrency currency;
final String label;
final bool isSelected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(80),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: colors.surfaceContainer,
borderRadius: BorderRadius.circular(80),
border: isSelected ? Border.all(color: colors.primary, width: 1.5) : null,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
TokenImageWidget(
imageUrl: currency.iconPath ?? '',
size: 24,
),
const SizedBox(width: 8),
Text(
label,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
],
),
),
);
}
}
enum _SelSection {
recents,
stablecoins,
cryptocurrencies,
moreCryptocurrencies,
xstocks,
allAssets,
}
@@ -0,0 +1,95 @@
import 'package:hash_wallet/exchange/trade.dart';
import 'package:hash_wallet/exchange/trade_state.dart';
import 'package:hash_wallet/new-ui/widgets/currency_picker/currency_picker_args.dart';
import 'package:hash_wallet/order/order.dart';
import 'package:cw_core/crypto_currency.dart';
import 'package:hive/hive.dart';
class PickerRecentsLoader {
const PickerRecentsLoader._();
static const int defaultLimit = 6;
static final Set<TradeState> _executedTradeStates = {
TradeState.complete,
TradeState.completed,
TradeState.finished,
TradeState.success,
TradeState.settled,
TradeState.traded,
};
static Future<List<CryptoCurrency>> load({
required RecentsSource source,
required List<CryptoCurrency> visibleItems,
int limit = defaultLimit,
}) async {
switch (source) {
case RecentsSource.none:
return const [];
case RecentsSource.trades:
return _fromTrades(visibleItems, limit);
case RecentsSource.orders:
return _fromOrders(visibleItems, limit);
}
}
static Future<List<CryptoCurrency>> _fromTrades(
List<CryptoCurrency> visibleItems,
int limit,
) async {
final trades = await Trade.getAll();
final ordered = trades.where((t) => _executedTradeStates.contains(t.state)).toList()
..sort((a, b) => (b.createdAt ?? DateTime.fromMillisecondsSinceEpoch(0))
.compareTo(a.createdAt ?? DateTime.fromMillisecondsSinceEpoch(0)));
return _collect(
ordered.expand((t) => [t.from, t.to]),
visibleItems,
limit,
);
}
static Future<List<CryptoCurrency>> _fromOrders(
List<CryptoCurrency> visibleItems,
int limit,
) async {
final orders = Hive.box<Order>(Order.boxName).values.toList()
..sort((a, b) => b.createdAt.compareTo(a.createdAt));
return _collect(
orders.expand((o) => [_tryResolve(o.from), _tryResolve(o.to)]),
visibleItems,
limit,
);
}
static List<CryptoCurrency> _collect(
Iterable<CryptoCurrency?> candidates,
List<CryptoCurrency> visibleItems,
int limit,
) {
final visibleSet = visibleItems.toSet();
final seen = <CryptoCurrency>{};
final collected = <CryptoCurrency>[];
for (final candidate in candidates) {
if (candidate == null) continue;
if (!visibleSet.contains(candidate)) continue;
if (!seen.add(candidate)) continue;
collected.add(candidate);
if (collected.length >= limit) break;
}
return collected;
}
static CryptoCurrency? _tryResolve(String? raw) {
if (raw == null || raw.isEmpty) return null;
try {
return CryptoCurrency.fromString(raw);
} catch (_) {
return null;
}
}
}
@@ -0,0 +1,21 @@
import 'package:flutter/material.dart';
class PickerSectionHeader extends StatelessWidget {
const PickerSectionHeader({super.key, required this.title});
final String title;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(4, 12, 4, 8),
child: Text(
title,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w500,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
);
}
}
@@ -0,0 +1,99 @@
import 'package:hash_wallet/new-ui/widgets/coins_page/token_image_widget.dart';
import 'package:hash_wallet/src/widgets/hash_image_widget.dart';
import 'package:cw_core/crypto_currency.dart';
import 'package:flutter/material.dart';
class PillGrid extends StatelessWidget {
const PillGrid({
super.key,
required this.items,
required this.selected,
required this.onTap,
required this.symbolResolver,
});
final List<CryptoCurrency> items;
final CryptoCurrency? selected;
final ValueChanged<CryptoCurrency> onTap;
final String Function(CryptoCurrency c) symbolResolver;
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
const gap = 8.0;
final cardWidth = (constraints.maxWidth - gap) / 2;
return Wrap(
spacing: gap,
runSpacing: gap,
children: [
for (final item in items)
SizedBox(
width: cardWidth,
child: _PillCard(
currency: item,
isSelected: selected != null && selected == item,
label: symbolResolver(item),
onTap: () => onTap(item),
),
),
],
);
},
);
}
}
class _PillCard extends StatelessWidget {
const _PillCard({
required this.currency,
required this.isSelected,
required this.label,
required this.onTap,
});
final CryptoCurrency currency;
final bool isSelected;
final String label;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(80),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 10),
decoration: BoxDecoration(
color: colors.surfaceContainer,
borderRadius: BorderRadius.circular(80),
border: isSelected ? Border.all(color: colors.primary, width: 1.5) : null,
),
child: Row(
children: [
TokenImageWidget(
imageUrl: currency.iconPath ?? '',
size: 24,
),
const SizedBox(width: 8),
Expanded(
child: Text(
label,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
),
Icon(
Icons.chevron_right,
size: 18,
color: colors.onSurfaceVariant,
),
],
),
),
);
}
}
@@ -0,0 +1,153 @@
import 'package:hash_wallet/generated/i18n.dart';
import 'package:hash_wallet/new-ui/widgets/currency_picker/currency_picker_args.dart';
import 'package:hash_wallet/new-ui/widgets/currency_picker/currency_picker_list_container.dart';
import 'package:hash_wallet/new-ui/widgets/receive_page/receive_top_bar.dart';
import 'package:hash_wallet/src/widgets/hash_image_widget.dart';
import 'package:cw_core/crypto_currency.dart';
import 'package:cw_core/currency_for_wallet_type.dart';
import 'package:cw_core/wallet_type.dart';
import 'package:flutter/material.dart';
class SelectNetworkPage extends StatelessWidget {
const SelectNetworkPage({
super.key,
required this.assetTitle,
required this.assetFullName,
required this.assetIconPath,
required this.variants,
required this.onSelected,
});
final String assetTitle;
final String? assetFullName;
final String? assetIconPath;
final List<CryptoCurrency> variants;
final ValueChanged<CryptoCurrency> onSelected;
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
return Scaffold(
backgroundColor: colors.surface,
body: SafeArea(
child: Column(
children: [
ModalTopBar(
title: S.of(context).select_network_title,
leadingIcon: const Icon(Icons.arrow_back),
leadingSemanticLabel: S.of(context).seed_alert_back,
onLeadingPressed: () => Navigator.of(context).maybePop(),
),
const SizedBox(height: 24),
// Illustration of the asset named by the title below it.
ExcludeSemantics(
child: HashImageWidget(
imageUrl: assetIconPath,
width: 75,
height: 75,
fit: BoxFit.cover,
),
),
const SizedBox(height: 12),
Text(
assetTitle,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontSize: 20,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 12),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 32),
child: Text(
S.of(context).select_network_subtitle,
textAlign: TextAlign.center,
style: Theme.of(context)
.textTheme
.bodyMedium
?.copyWith(color: colors.onSurfaceVariant),
),
),
const SizedBox(height: 24),
Expanded(
child: ListView(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
children: [
CurrencyPickerListContainer(
rows: [
for (final variant in variants)
_NetworkRow(
variant: variant,
onTap: () {
final nav = Navigator.of(context);
onSelected(variant);
if (nav.canPop()) nav.pop();
if (nav.canPop()) nav.pop();
},
),
],
),
],
),
),
],
),
),
);
}
}
class _NetworkRow extends StatelessWidget {
const _NetworkRow({required this.variant, required this.onTap});
final CryptoCurrency variant;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
final wt = cryptoCurrencyOrTokenToWalletType(variant);
final networkIconPath =
wt != null ? getCryptoCurrencyIconForWalletListItem(wt) : variant.iconPath;
final networkName = chainNameForCurrency(variant);
return MergeSemantics(
child: Semantics(
button: true,
child: InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14),
child: Row(
children: [
ExcludeSemantics(
child: HashImageWidget(
imageUrl: networkIconPath,
width: 28,
height: 28,
fit: BoxFit.cover,
),
),
const SizedBox(width: 12),
Expanded(
child: Text(
networkName,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w500,
),
),
),
ExcludeSemantics(
child: Icon(
Icons.chevron_right,
size: 20,
color: colors.onSurfaceVariant,
),
),
],
),
),
),
),
);
}
}
@@ -0,0 +1,196 @@
import "package:hash_wallet/generated/i18n.dart";
import "package:hash_wallet/new-ui/widgets/currency_picker/currency_picker_args.dart";
import "package:hash_wallet/new-ui/widgets/currency_picker/currency_picker_list_container.dart";
import "package:hash_wallet/new-ui/widgets/currency_picker/currency_picker_row.dart";
import "package:hash_wallet/new-ui/widgets/currency_picker/currency_picker_search_field.dart";
import "package:hash_wallet/new-ui/widgets/currency_picker/picker_section_header.dart";
import "package:hash_wallet/reactions/wallet_utils.dart";
import "package:cw_core/crypto_currency.dart";
import "package:cw_core/currency_for_wallet_type.dart";
import "package:cw_core/wallet_type.dart";
import "package:flutter/material.dart";
class SingleNetworkCurrencyPicker extends StatefulWidget {
const SingleNetworkCurrencyPicker({
required this.args,
super.key,
});
final CurrencyPickerArgs args;
@override
State<SingleNetworkCurrencyPicker> createState() => _SingleNetworkCurrencyPickerState();
}
class _SingleNetworkCurrencyPickerState extends State<SingleNetworkCurrencyPicker> {
CurrencyPickerArgs get _args => widget.args;
WalletType get _network => _args.filterByNetwork!;
final TextEditingController _searchController = TextEditingController();
@override
void initState() {
super.initState();
_searchController.addListener(() => setState(() {}));
}
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
void _selectCurrency(CryptoCurrency currency) {
_args.onSelected(currency);
Navigator.of(context).maybePop();
}
CurrencyPickerBalance? _balanceFor(CryptoCurrency c) => balanceForAsset(_args.balanceByAsset, c);
double _fiatValueFor(CryptoCurrency c) => _balanceFor(c)?.fiatValue ?? 0;
@override
Widget build(BuildContext context) {
final native = walletTypeToCryptoCurrency(_network);
final query = _searchController.text.trim();
final tokens = _args.items
.where(
(c) =>
c != native &&
cryptoCurrencyOrTokenToWalletType(c) == _network &&
currencyMatchesQuery(c, query),
)
.toList();
final insertOrder = {for (var i = 0; i < tokens.length; i++) tokens[i]: i};
tokens.sort((a, b) {
final av = _fiatValueFor(a);
final bv = _fiatValueFor(b);
if (bv != av) {
return bv.compareTo(av);
}
return (insertOrder[a] ?? 0).compareTo(insertOrder[b] ?? 0);
});
final nativeMatches = currencyMatchesQuery(native, query);
final showSectionHeaders = _network != WalletType.bitcoin;
return Column(
mainAxisSize: MainAxisSize.max,
children: [
Expanded(
child: (nativeMatches || tokens.isNotEmpty)
? ListView(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
children: showSectionHeaders
? [
if (nativeMatches) ...[
PickerSectionHeader(title: S.of(context).picker_section_gas_token),
CurrencyPickerListContainer(
rows: [
CurrencyPickerRow(
currency: native,
isSelected: _args.selected == native,
trailing: _BalanceTrailing(balance: _balanceFor(native)),
onTap: () => _selectCurrency(native),
),
],
),
const SizedBox(height: 16),
],
if (tokens.isNotEmpty) ...[
PickerSectionHeader(
title: S
.of(context)
.picker_section_tokens_standard(tokenStandardFor(_network)),
),
CurrencyPickerListContainer(
rows: [
for (final t in tokens)
CurrencyPickerRow(
currency: t,
isSelected: _args.selected == t,
trailing: _BalanceTrailing(balance: _balanceFor(t)),
onTap: () => _selectCurrency(t),
),
],
),
],
]
: [
CurrencyPickerListContainer(
rows: [
if (nativeMatches)
CurrencyPickerRow(
currency: native,
isSelected: _args.selected == native,
trailing: _BalanceTrailing(balance: _balanceFor(native)),
onTap: () => _selectCurrency(native),
),
for (final t in tokens)
CurrencyPickerRow(
currency: t,
isSelected: _args.selected == t,
trailing: _BalanceTrailing(balance: _balanceFor(t)),
onTap: () => _selectCurrency(t),
),
],
),
],
)
: Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Text(
S.of(context).picker_no_matches,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
),
),
),
CurrencyPickerSearchField(
controller: _searchController,
hintText: S.of(context).search,
),
],
);
}
}
class _BalanceTrailing extends StatelessWidget {
const _BalanceTrailing({required this.balance});
final CurrencyPickerBalance? balance;
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
final amount = balance?.amount ?? "—";
final fiat = balance?.fiat;
return Column(
crossAxisAlignment: CrossAxisAlignment.end,
mainAxisSize: MainAxisSize.min,
children: [
Text(
amount,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w500,
),
),
if (fiat != null && fiat.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 2),
child: Text(
fiat,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
fontWeight: FontWeight.w400,
color: colors.onSurfaceVariant,
),
),
),
],
);
}
}
+178
View File
@@ -0,0 +1,178 @@
import "dart:io";
import "package:flutter/material.dart";
import "package:flutter/services.dart";
class DigitInputController {
String _text = "";
String get text => _text;
set text(String newText) {
_text = newText;
for (final listener in _listeners) {
listener.call();
}
}
final List<void Function()> _listeners = [];
void addListener(void Function() listener) => _listeners.add(listener);
}
class DigitInputPill extends StatelessWidget {
const DigitInputPill({required this.highlighted, super.key, this.digit});
final String? digit;
final bool highlighted;
@override
Widget build(BuildContext context) => Container(
width: 51,
height: 84,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(16),
border: highlighted
? Border.all(color: Theme.of(context).colorScheme.primary, width: 2)
: null,
),
child: Center(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 16),
child: Text(digit ?? " ", style: const TextStyle(fontSize: 40)),
),
),
);
}
class DigitInput extends StatefulWidget {
const DigitInput({
required this.controller,
required this.desiredLength,
this.breakAt = 3,
super.key,
});
final DigitInputController controller;
final int desiredLength;
final int breakAt;
@override
State<DigitInput> createState() => _DigitInputState();
}
class _DigitInputState extends State<DigitInput> implements TextInputClient {
TextInputConnection? _textInputConnection;
@override
void initState() {
super.initState();
_openVirtualKeyboard();
}
void _openVirtualKeyboard() {
if (_textInputConnection == null || !_textInputConnection!.attached) {
_textInputConnection = TextInput.attach(
this,
TextInputConfiguration(
inputType: TextInputType.number,
inputAction: Platform.isIOS ? TextInputAction.done : TextInputAction.none,
),
);
}
_textInputConnection!.setEditingState(
TextEditingValue(
text: widget.controller.text,
selection: TextSelection.collapsed(offset: widget.controller.text.length),
),
);
_textInputConnection!.show();
}
void _closeVirtualKeyboard() => _textInputConnection?.close();
@override
void dispose() {
_closeVirtualKeyboard();
super.dispose();
}
@override
Widget build(BuildContext context) => GestureDetector(
onTap: _openVirtualKeyboard,
child: SizedBox(
height: 84,
child: ListView.separated(
scrollDirection: Axis.horizontal,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemBuilder: (context, index) => DigitInputPill(
digit: widget.controller.text.length > index ? widget.controller.text[index] : null,
highlighted: widget.controller.text.length == index,
),
separatorBuilder: (context, index) =>
SizedBox(width: (index + 1) % widget.breakAt == 0 ? 16 : 4),
itemCount: widget.desiredLength,
),
),
);
@override
void updateEditingValue(TextEditingValue value) {
final RegExp numberRegExp = RegExp(r"^\d{0,6}$");
if (numberRegExp.hasMatch(value.text)) {
setState(() => widget.controller.text = value.text);
} else {
// flutter keeps the text editing state regardless of whether you actually used it or not.
// so, if validation fails, we gotta set it back to what it was explicitly
_textInputConnection?.setEditingState(
TextEditingValue(
text: widget.controller.text,
selection: TextSelection.collapsed(offset: widget.controller.text.length),
),
);
}
}
@override
void performAction(TextInputAction action) {}
@override
TextEditingValue? get currentTextEditingValue => TextEditingValue(text: widget.controller.text);
@override
void connectionClosed() => _textInputConnection = null;
@override
AutofillScope? get currentAutofillScope => null;
@override
void performPrivateCommand(String action, Map<String, dynamic> data) {}
@override
void showAutocorrectionPromptRect(int start, int end) {}
@override
void updateFloatingCursor(RawFloatingCursorPoint point) {}
@override
void insertTextPlaceholder(Size size) {}
@override
void removeTextPlaceholder() {}
@override
void showToolbar() {}
@override
void performSelector(String selectorName) {}
@override
void didChangeInputControl(TextInputControl? oldControl, TextInputControl? newControl) {}
@override
void insertContent(KeyboardInsertedContent content) {}
}
+38 -31
View File
@@ -11,38 +11,45 @@ class DropdownRow extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainer,
borderRadius: BorderRadius.vertical(
top: Radius.circular(18),
bottom: expanded ? Radius.zero : Radius.circular(18),
return Semantics(
button: true,
expanded: expanded,
label: text,
onTap: onTap,
excludeSemantics: true,
child: Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainer,
borderRadius: BorderRadius.vertical(
top: Radius.circular(18),
bottom: expanded ? Radius.zero : Radius.circular(18),
),
),
),
height: 48,
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
text,
style: TextStyle(fontSize: 14, color: Theme.of(context).colorScheme.primary),
),
AnimatedRotation(
duration: Duration(milliseconds: 300),
turns: expanded ? 0.0 : 0.5,
curve: Curves.easeOut,
child: HashImageWidget(
imageUrl: "assets/new-ui/dropdown_arrow.svg",
colorFilter:
ColorFilter.mode(Theme.of(context).colorScheme.primary, BlendMode.srcIn),
))
],
height: 48,
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
text,
style: TextStyle(fontSize: 14, color: Theme.of(context).colorScheme.primary),
),
AnimatedRotation(
duration: Duration(milliseconds: 300),
turns: expanded ? 0.0 : 0.5,
curve: Curves.easeOut,
child: HashImageWidget(
imageUrl: "assets/new-ui/dropdown_arrow.svg",
colorFilter: ColorFilter.mode(
Theme.of(context).colorScheme.primary, BlendMode.srcIn),
))
],
),
),
),
),
@@ -0,0 +1,105 @@
import "package:hash_wallet/new-ui/widgets/money/currency_symbol_text.dart";
import "package:cw_core/currency.dart";
import "package:flutter/material.dart";
import "package:flutter/services.dart";
class FloatingAmountInput extends StatefulWidget {
const FloatingAmountInput({
required this.currency,
required this.controller,
super.key,
this.focusNode,
this.inputFormatters,
this.onChanged,
this.validator,
});
final Currency currency;
final TextEditingController controller;
final FocusNode? focusNode;
final List<TextInputFormatter>? inputFormatters;
final Function(String)? onChanged;
final FormFieldValidator<String>? validator;
@override
State<FloatingAmountInput> createState() => _FloatingAmountInputState();
}
class _FloatingAmountInputState extends State<FloatingAmountInput> {
bool _amountFocused = false;
late FocusNode focusNode = widget.focusNode ?? FocusNode();
@override
void initState() {
super.initState();
focusNode.addListener(() => setState(() => _amountFocused = focusNode.hasFocus));
}
@override
void dispose() {
if (focusNode != widget.focusNode) {
focusNode.dispose();
}
super.dispose();
}
@override
Widget build(BuildContext context) => Center(
child: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
children: [
IntrinsicWidth(
child: TextFormField(
controller: widget.controller,
focusNode: focusNode,
maxLines: 1,
onChanged: widget.onChanged,
autovalidateMode: AutovalidateMode.always,
validator: widget.validator,
keyboardType: const TextInputType.numberWithOptions(
signed: false,
decimal: true,
),
inputFormatters: <TextInputFormatter>[
FilteringTextInputFormatter.allow(
RegExp(r"^\d*[.,]?\d*$"),
),
],
decoration: InputDecoration(
isDense: true,
isCollapsed: true,
contentPadding: EdgeInsets.zero,
fillColor: Colors.transparent,
hoverColor: Colors.transparent,
focusedBorder: InputBorder.none,
enabledBorder: InputBorder.none,
hintText: _amountFocused || widget.controller.text.isNotEmpty ? null : "0.00",
hintStyle: Theme.of(context).textTheme.displayMedium?.copyWith(
fontWeight: FontWeight.w400,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
style: Theme.of(context).textTheme.displayMedium?.copyWith(
fontWeight: FontWeight.w400,
fontSize: 45,
color: Theme.of(context).colorScheme.onSurface,
),
),
),
const SizedBox(width: 8),
CurrencySymbolText(
widget.currency,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.displayMedium?.copyWith(
fontWeight: FontWeight.w400,
fontSize: 45,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
),
);
}
@@ -0,0 +1,77 @@
import 'package:hash_wallet/generated/i18n.dart';
import 'package:hash_wallet/src/widgets/hash_image_widget.dart';
import 'package:cw_core/wallet_info.dart';
import 'package:flutter/material.dart';
class HardwareWalletProceedOnDeviceMessage extends StatelessWidget {
const HardwareWalletProceedOnDeviceMessage({
super.key,
required this.hardwareWalletType,
this.note,
});
final HardwareWalletType hardwareWalletType;
/// Optional hint about what the device is going to ask
final String? note;
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
decoration: BoxDecoration(
border: Border.all(
color: Theme.of(context).colorScheme.primary,
),
borderRadius: BorderRadius.circular(16),
),
child: Padding(
padding: const EdgeInsets.all(12.0),
child: Column(spacing: 8, children: [
if (hardwareWalletIcon != null)
HashImageWidget(
imageUrl: hardwareWalletIcon!,
width: 36,
height: 36,
colorFilter: ColorFilter.mode(Theme.of(context).colorScheme.primary, BlendMode.srcIn),
),
Text(
S.of(context).proceed_on_device,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w400,
color: Theme.of(context).colorScheme.onSurface,
),
),
if (note != null)
Text(
note!,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w400,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
]),
),
);
}
String? get hardwareWalletIcon {
switch (hardwareWalletType) {
case HardwareWalletType.bitbox:
return "assets/new-ui/hardware_wallets/device_bitbox.svg";
case HardwareWalletType.ledger:
return "assets/new-ui/hardware_wallets/device_ledger_nano_x.svg";
case HardwareWalletType.trezor:
return "assets/new-ui/hardware_wallets/device_trezor_safe_7.svg";
case HardwareWalletType.cupcake:
return "assets/images/cupcake.svg";
case HardwareWalletType.coldcard:
case HardwareWalletType.seedsigner:
case HardwareWalletType.keystone:
return "assets/images/hardware_wallet/device_qr.svg";
}
}
}
@@ -0,0 +1,565 @@
import "package:hash_wallet/entities/new_ui_entities/list_item/list_item_regular_row.dart";
import "package:hash_wallet/entities/new_ui_entities/list_item/list_item_toggle.dart";
import "package:hash_wallet/src/screens/connect_device/monero_hardware_wallet_passphrase_input.dart";
import "package:hash_wallet/generated/i18n.dart";
import "package:hash_wallet/new-ui/widgets/bordered_svg.dart";
import "package:hash_wallet/new-ui/widgets/digit_input.dart";
import "package:hash_wallet/new-ui/widgets/modern_button.dart";
import "package:hash_wallet/new-ui/widgets/new_primary_button.dart";
import "package:hash_wallet/new-ui/widgets/receive_page/receive_top_bar.dart";
import "package:hash_wallet/new-ui/widgets/send_page/directional_switcher.dart";
import "package:hash_wallet/src/widgets/alert_with_two_actions.dart";
import "package:hash_wallet/src/widgets/base_alert_dialog.dart";
import "package:hash_wallet/src/widgets/hash_image_widget.dart";
import "package:hash_wallet/src/widgets/new_list_row/new_list_section.dart";
import "package:hash_wallet/utils/show_pop_up.dart";
import "package:hash_wallet/view_model/hardware_wallet/trezor_connect_view_model.dart";
import "package:cw_core/wallet_info.dart";
import "package:flutter/cupertino.dart";
import "package:flutter/material.dart";
import "package:mobx/mobx.dart";
class HardwareWalletProceedOnDeviceSheet extends StatefulWidget {
const HardwareWalletProceedOnDeviceSheet({
required this.hardwareWalletType,
required this.trezorConnectVM,
required this.onRetry,
super.key,
});
final HardwareWalletType hardwareWalletType;
final TrezorConnectViewModelBase trezorConnectVM;
final void Function() onRetry;
@override
State<HardwareWalletProceedOnDeviceSheet> createState() =>
_HardwareWalletProceedOnDeviceSheetState();
}
class _HardwareWalletProceedOnDeviceSheetState extends State<HardwareWalletProceedOnDeviceSheet> {
TrezorParingState _paringState = TrezorParingState.initial;
final DigitInputController _controller = DigitInputController();
static const pinOpenDuration = Duration(milliseconds: 300);
// you should probably check if there can be other pin lengths
static const pinLength = 6;
ReactionDisposer? paringStateReaction;
@override
void initState() {
super.initState();
_controller.addListener(() => setState(() {}));
_paringState = widget.trezorConnectVM.paringState;
paringStateReaction = reaction((_) => widget.trezorConnectVM.paringState, (paringState) {
if (paringState is SuccessTrezorParingState) {
if (mounted) {
Navigator.of(context).pop();
}
return;
}
setState(() => _paringState = paringState);
});
}
@override
void dispose() {
super.dispose();
paringStateReaction?.reaction.dispose();
}
bool get _isAwaitingPin => _paringState == TrezorParingState.enterPin;
@override
Widget build(BuildContext context) => PopScope(
canPop: false,
child: SafeArea(
bottom: false,
child: Container(
width: MediaQuery.of(context).size.width,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: const BorderRadius.vertical(top: Radius.circular(16)),
),
child: SafeArea(
child: Padding(
padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
child: Column(
children: [
ModalTopBar(
title: "",
leadingWidget: AnimatedSwitcher(
layoutBuilder: (currentChild, previousChildren) => Stack(
alignment: Alignment.centerLeft,
children: <Widget>[
...previousChildren,
if (currentChild != null) currentChild,
],
),
duration: pinOpenDuration,
child: Text(
key: ValueKey(pageTitle),
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w500),
pageTitle,
),
),
trailingIcon: Icon(
Icons.close,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
trailingSemanticLabel: S.of(context).close,
onTrailingPressed: () => showPopUp(
context: context,
builder: (context) => AlertWithTwoActions(
alertTitle: S.of(context).are_you_sure_exit,
alertContent: S.of(context).hww_exit_desc,
leftButtonText: S.of(context).cancel,
rightButtonText: S.of(context).yes_exit,
rightAlertButtonStyle: AlertButtonStyle.error(context),
actionLeftButton: Navigator.of(context).pop,
actionRightButton: () {
Navigator.of(context).pop();
Navigator.of(context).pop();
},
),
),
),
Expanded(
child: DirectionalAnimatedSwitcher(
duration: const Duration(milliseconds: 400),
child: content,
),
),
Padding(
padding: const EdgeInsets.only(bottom: 24, right: 24),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const SizedBox(),
AnimatedOpacity(
curve: Curves.easeOutQuad,
opacity: hasFullPin ? 1 : 0,
duration: pinOpenDuration,
child: ModernButton(
backgroundColor: Theme.of(context).colorScheme.primary,
iconColor: Theme.of(context).colorScheme.onPrimary,
size: 36,
icon: const Icon(Icons.arrow_forward),
semanticLabel: S.of(context).confirm,
onPressed: () =>
widget.trezorConnectVM.setParingPin(_controller.text.trim()),
),
),
],
),
),
],
),
),
),
),
),
);
Widget get content => switch (_paringState) {
InitialTrezorParingState() || EnterPinTrezorParingState() => PinEntryWidget(
title: S.of(context).proceed_on_device,
description: S.of(context).proceed_on_device_description,
pinOpenDuration: pinOpenDuration,
pinLength: pinLength,
isAwaitingPin: _isAwaitingPin,
controller: _controller,
iconPath: hardwareWalletIcon ?? "",
key: const ValueKey(0),
),
VerifyingPinTrezorParingState() => const VerifyingProgressIndicator(
key: ValueKey(1),
),
FailTrezorParingState(:final message) => ConnectionErrorWidget(
error: message,
onRetryPressed: retry,
key: const ValueKey(2),
),
AwaitingSettingsTrezorParingState(:final isAutoPairingAvailable) => WalletOptionsScreen(
trezorConnectVM: widget.trezorConnectVM,
iconPath: hardwareWalletIcon ?? "",
isAutoPairingAvailable: isAutoPairingAvailable,
key: const ValueKey(3),
),
AwaitingPassphraseTrezorParingState() => PinEntryWidget(
title: S.of(context).proceed_on_device,
description: S.of(context).proceed_on_device_description,
pinOpenDuration: pinOpenDuration,
pinLength: pinLength,
isAwaitingPin: false,
controller: _controller,
iconPath: hardwareWalletIcon ?? "",
key: const ValueKey(4),
),
_ => const SizedBox.shrink(
key: ValueKey(5),
),
};
void retry() {
_controller.text = "";
widget.onRetry();
}
String get pageTitle {
if (_paringState is InitialTrezorParingState) {
return S.of(context).device_confirmation;
}
if (_isAwaitingPin) {
return S.of(context).pairing_code;
}
if (_paringState is AwaitingSettingsTrezorParingState) {
return S.of(context).other_device_settings;
}
if (_paringState is AwaitingPassphraseTrezorParingState) {
return S.of(context).passphrase_entry;
}
return "";
}
bool get hasFullPin => _controller.text.length == pinLength && _isAwaitingPin;
String? get hardwareWalletIcon {
switch (widget.hardwareWalletType) {
case HardwareWalletType.bitbox:
return "assets/new-ui/hardware_wallets/device_bitbox.svg";
case HardwareWalletType.ledger:
return "assets/new-ui/hardware_wallets/device_ledger_nano_x.svg";
case HardwareWalletType.trezor:
return "assets/new-ui/hardware_wallets/device_trezor_safe_7.svg";
case HardwareWalletType.cupcake:
return "assets/images/cupcake.svg";
case HardwareWalletType.coldcard:
case HardwareWalletType.seedsigner:
case HardwareWalletType.keystone:
return "assets/images/hardware_wallet/device_qr.svg";
}
}
}
class ConnectionErrorWidget extends StatelessWidget {
const ConnectionErrorWidget({required this.error, required this.onRetryPressed, super.key});
final VoidCallback onRetryPressed;
final String error;
@override
Widget build(BuildContext context) => SizedBox(
key: key,
width: MediaQuery.of(context).size.width,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 18),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
spacing: 12,
children: [
const Spacer(),
Icon(
Icons.error_outline,
size: 80,
color: Theme.of(context).colorScheme.error,
),
Text(
S.of(context).pairing_error,
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.w500,
color: Theme.of(context).colorScheme.onSurface,
),
textAlign: TextAlign.center,
),
Text(
error,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w400,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
const Spacer(),
NewPrimaryButton(
onPressed: onRetryPressed,
text: S.of(context).try_again,
color: Theme.of(context).colorScheme.primary,
textColor: Theme.of(context).colorScheme.onPrimary,
),
],
),
),
);
}
class VerifyingProgressIndicator extends StatelessWidget {
const VerifyingProgressIndicator({super.key});
@override
Widget build(BuildContext context) => SizedBox(
key: key,
width: MediaQuery.of(context).size.width,
child: Column(
spacing: 12,
mainAxisAlignment: MainAxisAlignment.center,
children: [
const CupertinoActivityIndicator(radius: 36),
const SizedBox(),
Text(
"${S.of(context).verifying_code}...",
style: const TextStyle(fontWeight: FontWeight.w500, fontSize: 20),
),
Text(
S.of(context).this_can_take_few_seconds,
style: TextStyle(
fontWeight: FontWeight.w400,
fontSize: 16,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
),
);
}
class PinEntryWidget extends StatelessWidget {
const PinEntryWidget({
required this.pinOpenDuration,
required this.pinLength,
required this.controller,
required this.title,
required this.description,
required this.iconPath,
required this.isAwaitingPin,
super.key,
});
final DigitInputController controller;
final String iconPath;
final bool isAwaitingPin;
final int pinLength;
final Duration pinOpenDuration;
final String title;
final String description;
@override
Widget build(BuildContext context) => Column(
key: key,
spacing: 12,
mainAxisAlignment: MainAxisAlignment.center,
children: [
AnimatedContainer(
duration: pinOpenDuration,
curve: Curves.easeOutCubic,
width: isAwaitingPin ? 40 : 100,
height: isAwaitingPin ? 40 : 100,
child: HashImageWidget(
imageUrl: iconPath,
colorFilter: ColorFilter.mode(
Theme.of(context).colorScheme.onSurfaceVariant,
BlendMode.srcIn,
),
),
),
AnimatedSwitcher(
duration: pinOpenDuration,
switchInCurve: Curves.easeInCubic,
switchOutCurve: Curves.easeOutCubic,
child: isAwaitingPin
? Column(
spacing: 24,
key: const ValueKey(1),
children: [
Text(
textAlign: TextAlign.center,
"${S.of(context).pairing_code_desc_1}\n${S.of(context).pairing_code_desc_2}",
),
DigitInput(
controller: controller,
desiredLength: pinLength,
),
],
)
: Column(
key: const ValueKey(0),
spacing: 12,
children: [
Text(
title,
style: const TextStyle(fontWeight: FontWeight.w500, fontSize: 20),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Text(
description,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 16,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
),
],
),
),
],
);
}
class WalletOptionsScreen extends StatefulWidget {
const WalletOptionsScreen({
required this.trezorConnectVM,
required this.isAutoPairingAvailable,
required this.iconPath,
super.key,
});
final String iconPath;
final TrezorConnectViewModelBase trezorConnectVM;
final bool isAutoPairingAvailable;
@override
State<WalletOptionsScreen> createState() => _WalletOptionsScreenState();
}
class _WalletOptionsScreenState extends State<WalletOptionsScreen> {
bool _autoConnect = true;
bool _usePassphrase = false;
bool _setPassphraseOnDevice = false;
final _passphraseController = TextEditingController();
bool get anythingSelected => _autoConnect || _usePassphrase;
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.symmetric(horizontal: 18),
child: Column(
mainAxisSize: MainAxisSize.max,
children: [
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
spacing: 32,
children: [
Column(
spacing: 12,
children: [
SizedBox(
width: 100,
height: 116,
child: Stack(
clipBehavior: Clip.none,
children: [
Align(
alignment: Alignment.center,
child: HashImageWidget(
imageUrl: widget.iconPath,
width: 100,
height: 100,
colorFilter: ColorFilter.mode(
Theme.of(context).colorScheme.onSurfaceVariant,
BlendMode.srcIn,
),
),
),
const Positioned(
right: 0,
bottom: 0,
child: BorderedSvgIcon(
iconPath: "assets/new-ui/cog.svg",
iconSize: 36,
),
),
],
),
),
const SizedBox.shrink(),
Text(
S.of(context).almost_ready,
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w500),
),
Text(
S.of(context).hww_options_desc,
style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant),
textAlign: TextAlign.center,
),
],
),
NewListSections(
sections: {
if (widget.isAutoPairingAvailable)
"auto": [
ListItemToggle(
value: _autoConnect,
onChanged: (val) => setState(() => _autoConnect = val),
keyValue: "autoconnect",
label: S.of(context).auto_connect,
),
],
"pass": [
ListItemToggle(
value: _usePassphrase,
onChanged: (val) => setState(() => _usePassphrase = val),
keyValue: "passphrase",
label: S.of(context).passphrase,
subtitle: S.of(context).wallet_has_passphrase,
),
if (_usePassphrase) ...[
ListItemToggle(
value: _setPassphraseOnDevice,
onChanged: (val) => setState(() => _setPassphraseOnDevice = val),
keyValue: "passphrase on device",
label: S.of(context).enter_passphrase_on_device,
),
if (!_setPassphraseOnDevice)
ListItemRegularRow(
keyValue: "passphrase on app",
label: S.of(context).passphrase_raw,
onTap: () {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (_) => MoneroHardwareWalletPassphraseInputModal(
controller: _passphraseController,
),
);
},
),
]
],
},
),
],
),
),
NewPrimaryButton(
onPressed: () => widget.trezorConnectVM.setDeviceSettings(
TrezorDeviceSettings(
enableAutoParing: _autoConnect,
passphraseOnDevice: _usePassphrase && _setPassphraseOnDevice,
passphrase: _usePassphrase && !_setPassphraseOnDevice ? _passphraseController.text : null,
),
),
text: anythingSelected ? S.of(context).continue_text : S.of(context).skip,
color: anythingSelected
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.surfaceContainerHigh,
textColor: anythingSelected
? Theme.of(context).colorScheme.onPrimary
: Theme.of(context).colorScheme.primary,
),
],
),
);
}
@@ -0,0 +1,224 @@
import "package:hash_wallet/generated/i18n.dart";
import "package:hash_wallet/new-ui/widgets/receive_page/receive_top_bar.dart";
import "package:hash_wallet/new-ui/widgets/send_page/directional_switcher.dart";
import "package:hash_wallet/routes.dart";
import "package:hash_wallet/src/screens/connect_device/connect_device_page.dart";
import "package:hash_wallet/src/widgets/alert_with_two_actions.dart";
import "package:hash_wallet/src/widgets/base_alert_dialog.dart";
import "package:hash_wallet/src/widgets/hash_image_widget.dart";
import "package:hash_wallet/src/widgets/primary_button.dart";
import "package:hash_wallet/store/app_store.dart";
import "package:hash_wallet/utils/show_pop_up.dart";
import "package:hash_wallet/view_model/hardware_wallet/trezor_connect_view_model.dart";
import "package:cw_core/wallet_base.dart";
import "package:cw_core/wallet_info.dart";
import "package:flutter/cupertino.dart";
import "package:flutter/material.dart";
class SyncKeyImagesSheet extends StatefulWidget {
const SyncKeyImagesSheet({
required this.appStore,
required this.trezorConnectVM,
super.key,
});
final AppStore appStore;
final TrezorConnectViewModelBase trezorConnectVM;
WalletBase get wallet => appStore.wallet!;
@override
State<SyncKeyImagesSheet> createState() => _HardwareWalletProceedOnDeviceSheetState();
}
class _HardwareWalletProceedOnDeviceSheetState extends State<SyncKeyImagesSheet> {
_KeyImageSyncState _state = _KeyImageSyncState.initial;
@override
Widget build(BuildContext context) => PopScope(
canPop: false,
child: SafeArea(
bottom: false,
child: Container(
width: MediaQuery.of(context).size.width,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: const BorderRadius.vertical(top: Radius.circular(16)),
),
child: SafeArea(
child: Padding(
padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
child: Column(
children: [
ModalTopBar(
title: S.of(context).resync_device,
leadingIcon: Icon(
Icons.close,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
onLeadingPressed: () => showPopUp(
context: context,
builder: (context) => AlertWithTwoActions(
alertTitle: S.of(context).are_you_sure_exit,
alertContent: S.of(context).resync_device_cancel_warning_desc,
leftButtonText: S.of(context).cancel,
rightButtonText: S.of(context).yes_exit,
rightAlertButtonStyle: AlertButtonStyle.error(context),
actionLeftButton: Navigator.of(context).pop,
actionRightButton: () {
Navigator.of(context).pop();
Navigator.of(context).pop();
},
),
),
),
Expanded(
child: Column(
spacing: 12,
mainAxisAlignment: MainAxisAlignment.center,
children: [
HashImageWidget(
imageUrl: hardwareWalletIcon,
width: 100,
colorFilter: ColorFilter.mode(
Theme.of(context).colorScheme.onSurfaceVariant,
BlendMode.srcIn,
),
),
DirectionalAnimatedSwitcher(
duration: const Duration(milliseconds: 400),
child: content,
),
],
),
),
Padding(
padding: const EdgeInsets.all(24),
child: AnimatedOpacity(
curve: Curves.easeOutQuad,
opacity: _state == _KeyImageSyncState.initial ? 1 : 0,
duration: const Duration(milliseconds: 300),
child: PrimaryButton(
text: S.of(context).continue_text,
color: Theme.of(context).colorScheme.primary,
textColor: Theme.of(context).colorScheme.onPrimary,
onPressed: _onContinuePressed,
),
),
),
],
),
),
),
),
),
);
Widget get content {
if (_state == _KeyImageSyncState.syncing) {
return _syncingKeyImages();
}
return _initial();
}
String? get hardwareWalletIcon {
switch (widget.wallet.hardwareWalletType) {
case HardwareWalletType.bitbox:
return "assets/new-ui/hardware_wallets/device_bitbox.svg";
case HardwareWalletType.ledger:
return "assets/new-ui/hardware_wallets/device_ledger_nano_x.svg";
case HardwareWalletType.trezor:
return "assets/new-ui/hardware_wallets/device_trezor_safe_7.svg";
case HardwareWalletType.cupcake:
return "assets/images/cupcake.svg";
case HardwareWalletType.coldcard:
case HardwareWalletType.seedsigner:
case HardwareWalletType.keystone:
case null:
return "assets/images/hardware_wallet/device_qr.svg";
}
}
Widget _initial() => Column(
key: const ValueKey(0),
spacing: 12,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Text(
S.of(context).resync_device_desc,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 16,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
),
],
);
Widget _syncingKeyImages() => Column(
key: const ValueKey(1),
spacing: 12,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
S.of(context).proceed_on_device,
style: TextStyle(
fontWeight: FontWeight.w500,
fontSize: 20,
),
),
Padding(
padding: EdgeInsets.symmetric(horizontal: 20),
child: Text(
S.of(context).proceed_on_device_description,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 16,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
)
],
);
Future<void> _onContinuePressed() async {
setState(() => _state = _KeyImageSyncState.syncing);
if (!widget.trezorConnectVM.isConnected(widget.wallet.type)) {
await Navigator.of(context).pushNamed(
Routes.connectDevices,
arguments: ConnectDevicePageParams(
walletType: widget.wallet.type,
hardwareWalletType: widget.wallet.walletInfo.hardwareWalletType!,
onConnectDevice: (_, __) {
widget.trezorConnectVM.initWallet(widget.wallet);
Navigator.of(context).pop();
},
isReconnect: false,
),
);
// Recheck to handle tap-backs
if (!widget.trezorConnectVM.isConnected(widget.wallet.type)) {
setState(() => _state = _KeyImageSyncState.initial);
return;
}
} else {
await widget.trezorConnectVM.initWallet(widget.wallet);
}
final result = await widget.trezorConnectVM.syncKeyImages(widget.wallet);
if (result && context.mounted) {
Navigator.of(context).pop();
}
}
}
enum _KeyImageSyncState {
initial,
syncing;
}
@@ -0,0 +1,107 @@
import 'package:hash_wallet/generated/i18n.dart';
import 'package:hash_wallet/new-ui/widgets/new_primary_button.dart';
import 'package:hash_wallet/new-ui/widgets/receive_page/receive_top_bar.dart';
import 'package:flutter/material.dart';
class HardwareWalletTrezorParingSheet extends StatefulWidget {
const HardwareWalletTrezorParingSheet({super.key});
@override
State<HardwareWalletTrezorParingSheet> createState() => _HardwareWalletTrezorParingSheetState();
}
class _HardwareWalletTrezorParingSheetState extends State<HardwareWalletTrezorParingSheet> {
final TextEditingController _paringCodeController = TextEditingController();
@override
Widget build(BuildContext context) {
return Padding(
padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
child: SafeArea(
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.vertical(top: Radius.circular(18)),
color: Theme.of(context).colorScheme.surface,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ModalTopBar(
title: "Paring code",
onLeadingPressed: Navigator.of(context).pop,
onTrailingPressed: () {},
leadingIcon: Icon(Icons.close),
leadingSemanticLabel: S.of(context).close,
),
Padding(
padding: const EdgeInsets.all(18.0),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 12,
children: [
SizedBox(),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Expanded(
flex: 75,
child: AnimatedContainer(
duration: Duration(milliseconds: 300),
height: 60,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainer,
borderRadius: BorderRadius.only(
topLeft: Radius.circular(16),
bottomLeft: Radius.circular(16),
),
border: Border.all(
color: Theme.of(context).colorScheme.surfaceContainer,
width: 2,
),
),
child: TextField(
textAlign: TextAlign.left,
textAlignVertical: TextAlignVertical.center,
controller: _paringCodeController,
keyboardType:
TextInputType.numberWithOptions(signed: false, decimal: false),
decoration: InputDecoration(
hint: Text(
"000 000",
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 16,
color: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.5),
),
),
border: InputBorder.none,
filled: true,
fillColor: Colors.transparent,
),
style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
),
),
),
],
),
SizedBox(),
NewPrimaryButton(
text: S.of(context).continue_text,
onPressed: () => Navigator.of(context).pop(_paringCodeController.text),
color: Theme.of(context).colorScheme.primary,
textColor: Theme.of(context).colorScheme.onPrimary,
)
],
),
)
],
),
),
),
);
}
}
+56 -43
View File
@@ -1,3 +1,4 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
@@ -29,7 +30,7 @@ class _LineTabSwitcherState extends State<LineTabSwitcher> {
void didUpdateWidget(covariant LineTabSwitcher oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.tabs.length != widget.tabs.length) {
if (!listEquals(oldWidget.tabs, widget.tabs)) {
textWidgetKeys = List.generate(widget.tabs.length, (_) => GlobalKey());
textWidgetSizes = [];
totalWidth = 0;
@@ -92,58 +93,70 @@ class _LineTabSwitcherState extends State<LineTabSwitcher> {
children: widget.tabs.map((item) {
final index = widget.tabs.indexOf(item);
return GestureDetector(
onTap: () {
widget.onTabChange(index);
},
child: Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
AnimatedDefaultTextStyle(
duration: Duration(milliseconds: 150),
style: DefaultTextStyle.of(context).style.copyWith(
inherit: true,
fontSize: 16,
fontWeight: FontWeight.w500,
color: widget.selectedTab == index
? Theme.of(context).colorScheme.onSurface
: Theme.of(context).colorScheme.onSurfaceVariant,
// One tab = one node: the visible label merges into a button node
// that also carries the selected state of this tab.
return MergeSemantics(
child: Semantics(
button: true,
selected: widget.selectedTab == index,
inMutuallyExclusiveGroup: true,
child: GestureDetector(
key: ValueKey("line_tab_switcher_${index}_key"),
onTap: () {
widget.onTabChange(index);
},
child: Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
AnimatedDefaultTextStyle(
duration: Duration(milliseconds: 150),
style: DefaultTextStyle.of(context).style.copyWith(
inherit: true,
fontSize: 16,
fontWeight: FontWeight.w500,
color: widget.selectedTab == index
? Theme.of(context).colorScheme.onSurface
: Theme.of(context).colorScheme.onSurfaceVariant,
),
child: Padding(
padding: EdgeInsets.symmetric(horizontal: itemPadding / 2),
child: Text(
item,
key: textWidgetKeys[index],
),
),
child: Padding(
padding: EdgeInsets.symmetric(horizontal: itemPadding / 2),
child: Text(
item,
key: textWidgetKeys[index],
),
),
],
),
],
),
),
);
}).toList())),
Container(
width: totalWidth,
height: 2,
child: Stack(
children: [
AnimatedPositioned(
curve: Curves.easeOut,
left: _calcBarLeft(),
bottom: 0,
duration: Duration(milliseconds: 150),
child: AnimatedSize(
if (widget.tabs.length > 1)
Container(
width: totalWidth,
height: 2,
child: Stack(
children: [
AnimatedPositioned(
curve: Curves.easeOut,
left: _calcBarLeft(),
bottom: 0,
duration: Duration(milliseconds: 150),
child: Container(
height: 2,
width: textWidgetSizes.isEmpty ? 0 : textWidgetSizes[widget.selectedTab].width,
color: Theme.of(context).colorScheme.onSurface,
child: AnimatedSize(
duration: Duration(milliseconds: 150),
child: Container(
height: 2,
width:
textWidgetSizes.isEmpty ? 0 : textWidgetSizes[widget.selectedTab].width,
color: Theme.of(context).colorScheme.onSurface,
),
),
),
),
],
],
),
),
),
],
);
}
-101
View File
@@ -1,101 +0,0 @@
import 'dart:ui';
import 'package:hash_wallet/src/widgets/hash_image_widget.dart';
import 'package:flutter/material.dart';
import 'package:flutter_svg/svg.dart';
class LongPressMenuItem {
final String label;
final String iconPath;
final VoidCallback onSelected;
final Color? color;
LongPressMenuItem(
{required this.label, required this.iconPath, required this.onSelected, this.color});
}
class LongPressMenu extends StatefulWidget {
const LongPressMenu({super.key, required this.items});
final List<LongPressMenuItem> items;
@override
State<LongPressMenu> createState() => _LongPressMenuState();
}
class _LongPressMenuState extends State<LongPressMenu> {
bool _isVisible = false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
setState(() {
_isVisible = true;
});
});
}
@override
Widget build(BuildContext context) {
return AnimatedSize(
duration: Duration(milliseconds: 300),
curve: Curves.easeOutCubic,
alignment: Alignment.topCenter,
child: SizedBox(
height: _isVisible ? null : 0,
child: ClipRRect(
borderRadius: BorderRadius.circular(20),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 10.0, sigmaY: 10.0),
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
color: Theme.of(context).colorScheme.primary.withAlpha(60),
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: widget.items.map((item) {
final color = item.color ?? Theme.of(context).colorScheme.onSurface;
return Material(
color: Colors.transparent,
child: InkWell(
onTap: item.onSelected,
child: Padding(
padding: EdgeInsets.only(
left: 16,
right: 16,
top: 12,
bottom: 12,
),
child: Container(
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.start,
spacing: 8,
children: [
HashImageWidget(
imageUrl: item.iconPath,
height: 20,
width: 20,
colorFilter: ColorFilter.mode(color, BlendMode.srcIn),
),
Text(item.label,
style: TextStyle(
color: color, fontSize: 14, fontWeight: FontWeight.w500)),
],
),
),
),
),
);
}).toList(),
),
),
),
),
),
);
}
}
@@ -0,0 +1,42 @@
import "package:hash_wallet/src/widgets/hash_image_widget.dart";
import "package:flutter/material.dart";
class LongPressFooter extends StatelessWidget {
const LongPressFooter({required this.text, super.key});
final String text;
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.all(12),
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
color: Theme.of(context).colorScheme.primary.withAlpha(60),
),
child: Padding(
padding: const EdgeInsets.all(12),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
spacing: 12,
children: [
HashImageWidget(
imageUrl: "assets/new-ui/info.svg",
width: 18,
height: 18,
colorFilter: ColorFilter.mode(
Theme.of(context).colorScheme.onSurfaceVariant,
BlendMode.srcIn,
),
),
Text(
text,
style: TextStyle(
fontSize: 12, color: Theme.of(context).colorScheme.onSurfaceVariant),
),
],
),
),
),
);
}
@@ -0,0 +1,112 @@
import "dart:ui";
import "package:hash_wallet/src/widgets/hash_image_widget.dart";
import "package:flutter/material.dart";
class LongPressMenuItem {
LongPressMenuItem({
required this.label,
required this.iconPath,
required this.onSelected,
this.color,
});
final String label;
final String iconPath;
final VoidCallback onSelected;
final Color? color;
}
class LongPressMenu extends StatefulWidget {
const LongPressMenu({required this.items, super.key});
final List<LongPressMenuItem> items;
@override
State<LongPressMenu> createState() => _LongPressMenuState();
}
class _LongPressMenuState extends State<LongPressMenu> {
bool _isVisible = false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
setState(() {
_isVisible = true;
});
});
}
@override
Widget build(BuildContext context) => AnimatedSize(
duration: const Duration(milliseconds: 300),
curve: Curves.easeOutCubic,
alignment: Alignment.topCenter,
child: SizedBox(
height: _isVisible ? null : 0,
child: ClipRRect(
borderRadius: BorderRadius.circular(20),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10),
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
color: Theme.of(context).colorScheme.primary.withAlpha(60),
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: widget.items.map((item) {
final color = item.color ?? Theme.of(context).colorScheme.onSurface;
return Material(
color: Colors.transparent,
child: MergeSemantics(
child: Semantics(
button: true,
child: InkWell(
onTap: item.onSelected,
child: Padding(
padding: const EdgeInsets.only(
left: 16,
right: 16,
top: 12,
bottom: 12,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.start,
spacing: 8,
children: [
ExcludeSemantics(
child: HashImageWidget(
imageUrl: item.iconPath,
height: 20,
width: 20,
colorFilter: ColorFilter.mode(color, BlendMode.srcIn),
),
),
Text(
item.label,
style: TextStyle(
color: color,
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
],
),
),
),
),
),
);
}).toList(),
),
),
),
),
),
);
}
@@ -0,0 +1,80 @@
import "dart:ui";
import "package:hash_wallet/utils/show_pop_up.dart";
import "package:flutter/material.dart";
class LongPressPopupBuilder extends StatelessWidget {
const LongPressPopupBuilder({
required this.child,
required this.popup,
super.key,
this.spacing = 8,
this.showOnTap = false,
this.footer,
});
final Widget child;
final Widget popup;
final Widget? footer;
final double spacing;
final bool showOnTap;
@override
Widget build(BuildContext context) => GestureDetector(
behavior: HitTestBehavior.translucent,
onLongPress: () => _showMenu(context),
onTap: showOnTap ? () => _showMenu(context) : null,
child: IgnorePointer(ignoring: showOnTap, child: child),
);
void _showMenu(BuildContext context) {
final RenderBox renderBox = context.findRenderObject()! as RenderBox;
final offset = renderBox.localToGlobal(Offset.zero);
final size = renderBox.size;
showPopUp(
context: context,
builder: (context) => _buildPopup(context, offset, size),
);
}
Widget _buildPopup(BuildContext context, Offset offset, Size size) {
final screenWidth = MediaQuery.of(context).size.width;
final bool isOnRightHalf = offset.dx + (size.width / 2) > screenWidth / 2;
return BackdropFilter(
filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10),
child: Stack(
children: [
Positioned(
left: offset.dx,
top: offset.dy,
width: size.width,
height: size.height,
child: Material(
color: Colors.transparent,
child: child,
),
),
Positioned(
left: isOnRightHalf ? null : offset.dx,
right: isOnRightHalf ? (screenWidth - (offset.dx + size.width)) : null,
top: offset.dy + size.height + spacing,
child: Material(
color: Colors.transparent,
child: popup,
),
),
if (footer != null)
Positioned(
left: 0,
right: 0,
bottom: MediaQuery.of(context).viewPadding.bottom,
child: footer!,
),
],
),
);
}
}
+46 -28
View File
@@ -1,14 +1,18 @@
import 'package:hash_wallet/src/widgets/hash_image_widget.dart';
import 'package:flutter/material.dart';
import 'package:flutter_svg/svg.dart';
class ModalHeader extends StatelessWidget {
const ModalHeader(
{super.key, required this.iconPath, required this.message, required this.title});
{super.key,
required this.iconPath,
required this.message,
required this.title,
this.bottomWidget});
final String iconPath;
final String title;
final String message;
final Widget? bottomWidget;
@override
Widget build(BuildContext context) {
@@ -16,33 +20,47 @@ class ModalHeader extends StatelessWidget {
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainer,
borderRadius: BorderRadius.circular(16)),
child: Padding(
padding: const EdgeInsets.all(12.0),
child: Column(
spacing: 10,
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
HashImageWidget(imageUrl: iconPath, width: 36, height: 36),
Text(
title,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
color: Theme.of(context).colorScheme.onSurface),
),
Padding(
padding: EdgeInsets.symmetric(horizontal: 32),
child: Text(
message,
child: Column(
children: [
Padding(
padding: const EdgeInsets.all(12.0),
child: Column(
spacing: 10,
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
HashImageWidget(imageUrl: iconPath, width: 36, height: 36),
Text(
title,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w400,
color: Theme.of(context).colorScheme.onSurfaceVariant),
textAlign: TextAlign.center,
))
],
),
fontSize: 16,
fontWeight: FontWeight.w500,
color: Theme.of(context).colorScheme.onSurface),
),
Padding(
padding: EdgeInsets.symmetric(horizontal: 32),
child: Text(
message,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w400,
color: Theme.of(context).colorScheme.onSurfaceVariant),
textAlign: TextAlign.center,
)),
],
),
),
if (bottomWidget != null) ...[
Padding(
padding: EdgeInsets.symmetric(horizontal: 18),
child:
Container(height: 1, color: Theme.of(context).colorScheme.surfaceContainerHigh),
),
ClipRRect(
borderRadius: BorderRadius.vertical(bottom: Radius.circular(16)),
child: bottomWidget!)
]
],
),
);
}
+44 -26
View File
@@ -11,6 +11,11 @@ class ModernButton extends StatelessWidget {
final Color? backgroundColor;
final double? iconSize;
final String? label;
/// Accessible name announced by screen readers. Falls back to [label] (the
/// visible caption) when omitted, so one of the two is required. Must be
/// localized by the caller.
final String? semanticLabel;
static const iconSvgSizeRatio = 2 / 3;
const ModernButton(
@@ -21,7 +26,8 @@ class ModernButton extends StatelessWidget {
this.iconSize,
this.iconColor,
this.backgroundColor,
this.label})
this.label,
this.semanticLabel})
: svgPath = null;
const ModernButton.svg(
@@ -32,7 +38,8 @@ class ModernButton extends StatelessWidget {
this.iconSize,
this.iconColor,
this.backgroundColor,
this.label})
this.label,
this.semanticLabel})
: icon = null;
@override
@@ -56,31 +63,42 @@ class ModernButton extends StatelessWidget {
child: icon!,
);
return Column(
spacing: 8,
children: [
Container(
decoration: BoxDecoration(
color: resolvedBackgroundColor,
borderRadius: BorderRadius.circular(size),
),
width: size,
height: size,
child: IconButton(
padding: EdgeInsets.zero,
onPressed: onPressed,
icon: resolvedIcon,
),
// A single accessibility node for the whole control: the visible caption
// duplicates the accessible name, so it is excluded to avoid a second,
// detached stop for screen readers.
return MergeSemantics(
child: Semantics(
button: true,
label: semanticLabel ?? label,
child: Column(
spacing: 8,
children: [
Container(
decoration: BoxDecoration(
color: resolvedBackgroundColor,
borderRadius: BorderRadius.circular(size),
),
width: size,
height: size,
child: IconButton(
padding: EdgeInsets.zero,
onPressed: onPressed,
icon: ExcludeSemantics(child: resolvedIcon),
),
),
if (label != null && label!.isNotEmpty)
ExcludeSemantics(
child: Text(
label!,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w400,
color: Theme.of(context).colorScheme.onSurface),
),
)
],
),
if (label != null && label!.isNotEmpty)
Text(
label!,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w400,
color: Theme.of(context).colorScheme.onSurface),
)
],
),
);
}
}
@@ -0,0 +1,165 @@
import "package:hash_wallet/new-ui/widgets/money/money_settings_cubit.dart";
import "package:cw_core/amount/money.dart";
import "package:cw_core/currency.dart";
import "package:flutter/widgets.dart";
import "package:flutter_bloc/flutter_bloc.dart";
/// The [CurrencySymbolText] widget displays [Currency.symbol] based on
/// usersettings as a formated string of text with single style.
class CurrencySymbolText extends StatelessWidget {
const CurrencySymbolText(
this.currency, {
super.key,
this.style,
this.strutStyle,
this.textAlign,
this.textDirection,
this.locale,
this.softWrap,
this.overflow,
this.textScaler,
this.maxLines,
this.semanticsLabel,
this.semanticsIdentifier,
this.textWidthBasis,
this.textHeightBehavior,
this.selectionColor,
this.useBaseUnit,
});
/// The currency to display.
final Currency currency;
/// If non-null, the style to use for this text.
///
/// If the style's "inherit" property is true, the style will be merged with
/// the closest enclosing [DefaultTextStyle]. Otherwise, the style will
/// replace the closest enclosing [DefaultTextStyle].
///
/// The user or platform may override this [style]'s [TextStyle.fontWeight],
/// [TextStyle.height], [TextStyle.letterSpacing], and [TextStyle.wordSpacing]
/// via a [MediaQuery] ancestor's [MediaQueryData.boldText],
/// [MediaQueryData.lineHeightScaleFactorOverride],
/// [MediaQueryData.letterSpacingOverride], and [MediaQueryData.wordSpacingOverride]
/// regardless of its [TextStyle.inherit] value.
final TextStyle? style;
/// The user or platform may override this [strutStyle]'s [StrutStyle.height]
/// via a [MediaQuery] ancestor's [MediaQueryData.lineHeightScaleFactorOverride].
final StrutStyle? strutStyle;
/// How the text should be aligned horizontally.
final TextAlign? textAlign;
/// The directionality of the text.
///
/// This decides how [textAlign] values like [TextAlign.start] and
/// [TextAlign.end] are interpreted.
///
/// This is also used to disambiguate how to render bidirectional text. For
/// example, if the [data] is an English phrase followed by a Hebrew phrase,
/// in a [TextDirection.ltr] context the English phrase will be on the left
/// and the Hebrew phrase to its right, while in a [TextDirection.rtl]
/// context, the English phrase will be on the right and the Hebrew phrase on
/// its left.
///
/// Defaults to the ambient [Directionality], if any.
final TextDirection? textDirection;
/// Used to select a font when the same Unicode character can
/// be rendered differently, depending on the locale.
///
/// It's rarely necessary to set this property. By default its value
/// is inherited from the enclosing app with `Localizations.localeOf(context)`.
///
/// See [RenderParagraph.locale] for more information.
final Locale? locale;
/// Whether the text should break at soft line breaks.
///
/// If false, the glyphs in the text will be positioned as if there was unlimited horizontal space.
final bool? softWrap;
/// How visual overflow should be handled.
///
/// If this is null [TextStyle.overflow] will be used, otherwise the value
/// from the nearest [DefaultTextStyle] ancestor will be used.
final TextOverflow? overflow;
final TextScaler? textScaler;
/// An optional maximum number of lines for the text to span, wrapping if necessary.
/// If the text exceeds the given number of lines, it will be truncated according
/// to [overflow].
///
/// If this is 1, text will not wrap. Otherwise, text will be wrapped at the
/// edge of the box.
///
/// If this is null, but there is an ambient [DefaultTextStyle] that specifies
/// an explicit number for its [DefaultTextStyle.maxLines], then the
/// [DefaultTextStyle] value will take precedence. You can use a [RichText]
/// widget directly to entirely override the [DefaultTextStyle].
final int? maxLines;
/// An alternative semantics label for this text.
///
/// If present, the semantics of this widget will contain this value instead
/// of the actual text. This will overwrite any of the semantics labels applied
/// directly to the [TextSpan]s.
///
/// This is useful for replacing abbreviations or shorthands with the full
/// text value:
///
/// ```dart
/// const Text(r'$$', semanticsLabel: 'Double dollars')
/// ```
final String? semanticsLabel;
/// A unique identifier for the semantics node for this widget.
///
/// This is useful for cases where the text widget needs to have a uniquely
/// identifiable ID that is recognized through the automation tools without
/// having a dependency on the actual content of the text that can possibly be
/// dynamic in nature.
final String? semanticsIdentifier;
final TextWidthBasis? textWidthBasis;
final TextHeightBehavior? textHeightBehavior;
/// The color to use when painting the selection.
///
/// This is ignored if [SelectionContainer.maybeOf] returns null
/// in the [BuildContext] of the [Text] widget.
///
/// If null, the ambient [DefaultSelectionStyle] is used (if any); failing
/// that, the selection color defaults to [DefaultSelectionStyle.defaultColor]
/// (semi-transparent grey).
final Color? selectionColor;
/// Show the amount in the base unit format of [Money.currency]
///
/// If null, the displayAmountsInSatoshi setting is used.
final bool? useBaseUnit;
@override
Widget build(BuildContext context) => BlocBuilder<MoneySettingsCubit, MoneySettingsState>(
builder: (context, state) => Text(
state.getSymbol(currency, overrideSettings: useBaseUnit),
style: style,
strutStyle: strutStyle,
textAlign: textAlign,
textDirection: textDirection,
locale: locale,
softWrap: softWrap,
overflow: overflow,
textScaler: textScaler,
maxLines: maxLines,
semanticsLabel: semanticsLabel,
semanticsIdentifier: semanticsIdentifier,
textWidthBasis: textWidthBasis,
textHeightBehavior: textHeightBehavior,
selectionColor: selectionColor,
),
);
}
@@ -0,0 +1,78 @@
import "package:bloc/bloc.dart";
import "package:hash_wallet/entities/balance_display_mode.dart";
import "package:hash_wallet/entities/bitcoin_amount_display_mode.dart";
import "package:hash_wallet/src/screens/wallet_connect/utils/string_parsing.dart";
import "package:hash_wallet/store/settings_store.dart";
import "package:cw_core/crypto_currency.dart";
import "package:cw_core/currency.dart";
import "package:mobx/mobx.dart";
class MoneySettingsCubit extends Cubit<MoneySettingsState> {
MoneySettingsCubit(SettingsStore _settingsStore)
: super(
MoneySettingsState(
bitcoinAmountDisplayMode: _settingsStore.displayAmountsInSatoshi,
displayMode: _settingsStore.balanceDisplayMode,
),
) {
_bitcoinAmountDisplayModeDisposer = reaction(
(_) => _settingsStore.displayAmountsInSatoshi,
(displayMode) => emit(state.copyWith(bitcoinAmountDisplayMode: displayMode)),
);
_displayModeDisposer = reaction(
(_) => _settingsStore.balanceDisplayMode,
(displayMode) => emit(state.copyWith(displayMode: displayMode)),
);
}
late final ReactionDisposer _bitcoinAmountDisplayModeDisposer;
late final ReactionDisposer _displayModeDisposer;
@override
Future<void> close() {
if (!_bitcoinAmountDisplayModeDisposer.reaction.isDisposed) {
_bitcoinAmountDisplayModeDisposer.reaction.dispose();
}
if (!_displayModeDisposer.reaction.isDisposed) {
_displayModeDisposer.reaction.dispose();
}
return super.close();
}
}
class MoneySettingsState {
const MoneySettingsState({required this.bitcoinAmountDisplayMode, required this.displayMode});
final BitcoinAmountDisplayMode bitcoinAmountDisplayMode;
final BalanceDisplayMode displayMode;
bool useBaseUnit(Currency currency) =>
([CryptoCurrency.btc, CryptoCurrency.btcln].contains(currency) &&
bitcoinAmountDisplayMode == BitcoinAmountDisplayMode.satoshi) ||
(CryptoCurrency.btcln == currency &&
bitcoinAmountDisplayMode == BitcoinAmountDisplayMode.satoshiForLightning);
bool get isHidden => displayMode == BalanceDisplayMode.hiddenBalance;
String getSymbol(Currency currency, {bool? overrideSettings}) {
if (overrideSettings == null) {
return useBaseUnit(currency) ? "sats" : currency.symbol.safeSubString(0, 8);
}
if (overrideSettings == true && [CryptoCurrency.btc, CryptoCurrency.btcln].contains(currency)) {
return "sats";
}
return currency.symbol.safeSubString(0, 8);
}
MoneySettingsState copyWith({
BitcoinAmountDisplayMode? bitcoinAmountDisplayMode,
BalanceDisplayMode? displayMode,
}) =>
MoneySettingsState(
bitcoinAmountDisplayMode: bitcoinAmountDisplayMode ?? this.bitcoinAmountDisplayMode,
displayMode: displayMode ?? this.displayMode,
);
}
@@ -0,0 +1,20 @@
import "package:hash_wallet/new-ui/widgets/money/money_settings_cubit.dart";
import "package:hash_wallet/new-ui/widgets/money/use_base_unit_provider.dart";
import "package:hash_wallet/store/settings_store.dart";
import "package:flutter/material.dart";
import "package:flutter_bloc/flutter_bloc.dart";
class MoneySettingsProvider extends StatelessWidget {
const MoneySettingsProvider({required this.settingsStore, required this.child, super.key});
final Widget child;
final SettingsStore settingsStore;
@override
Widget build(BuildContext context) => BlocProvider(
create: (_) => MoneySettingsCubit(settingsStore),
child: BaseUnitScope(
child: child,
),
);
}
+256
View File
@@ -0,0 +1,256 @@
import "package:hash_wallet/generated/i18n.dart";
import "package:hash_wallet/new-ui/widgets/money/money_settings_cubit.dart";
import "package:cw_core/amount/money.dart";
import "package:flutter/widgets.dart";
import "package:flutter_bloc/flutter_bloc.dart";
/// The [MoneyText] widget displays [Money] as a formated string of text with single style.
class MoneyText extends StatelessWidget {
const MoneyText(
this.amount, {
super.key,
this.style,
this.strutStyle,
this.textAlign,
this.textDirection,
this.locale,
this.softWrap,
this.overflow,
this.textScaler,
this.maxLines,
this.semanticsLabel,
this.semanticsIdentifier,
this.textWidthBasis,
this.textHeightBehavior,
this.selectionColor,
this.isHiddenAmount,
this.useBaseUnit,
this.fractionalDigits = 8,
this.showSymbol = true,
this.withSymbolPrefix = false,
this.trimZeros = true,
});
/// The [MoneyText.optional] returns a widget displaying [Money] as a
/// formated string or an [SizedBox.shrink].
static Widget optional(
Money? amount, {
Key? key,
TextStyle? style,
StrutStyle? strutStyle,
TextAlign? textAlign,
TextDirection? textDirection,
Locale? locale,
bool? softWrap,
TextOverflow? overflow,
TextScaler? textScaler,
int? maxLines,
String? semanticsLabel,
String? semanticsIdentifier,
TextWidthBasis? textWidthBasis,
TextHeightBehavior? textHeightBehavior,
Color? selectionColor,
bool? isHiddenAmount,
bool? useBaseUnit,
int fractionalDigits = 8,
bool showSymbol = true,
bool withSymbolPrefix = false,
bool trimZeros = true,
}) =>
amount != null
? MoneyText(
amount,
key: key,
style: style,
strutStyle: strutStyle,
textAlign: textAlign,
textDirection: textDirection,
locale: locale,
softWrap: softWrap,
overflow: overflow,
textScaler: textScaler,
maxLines: maxLines,
semanticsLabel: semanticsLabel,
semanticsIdentifier: semanticsIdentifier,
textWidthBasis: textWidthBasis,
textHeightBehavior: textHeightBehavior,
selectionColor: selectionColor,
isHiddenAmount: isHiddenAmount,
useBaseUnit: useBaseUnit,
fractionalDigits: fractionalDigits,
showSymbol: showSymbol,
withSymbolPrefix: withSymbolPrefix,
trimZeros: trimZeros,
)
: const SizedBox.shrink();
/// The amount to display.
final Money amount;
/// If non-null, the style to use for this text.
///
/// If the style's "inherit" property is true, the style will be merged with
/// the closest enclosing [DefaultTextStyle]. Otherwise, the style will
/// replace the closest enclosing [DefaultTextStyle].
///
/// The user or platform may override this [style]'s [TextStyle.fontWeight],
/// [TextStyle.height], [TextStyle.letterSpacing], and [TextStyle.wordSpacing]
/// via a [MediaQuery] ancestor's [MediaQueryData.boldText],
/// [MediaQueryData.lineHeightScaleFactorOverride],
/// [MediaQueryData.letterSpacingOverride], and [MediaQueryData.wordSpacingOverride]
/// regardless of its [TextStyle.inherit] value.
final TextStyle? style;
/// The user or platform may override this [strutStyle]'s [StrutStyle.height]
/// via a [MediaQuery] ancestor's [MediaQueryData.lineHeightScaleFactorOverride].
final StrutStyle? strutStyle;
/// How the text should be aligned horizontally.
final TextAlign? textAlign;
/// The directionality of the text.
///
/// This decides how [textAlign] values like [TextAlign.start] and
/// [TextAlign.end] are interpreted.
///
/// This is also used to disambiguate how to render bidirectional text. For
/// example, if the [data] is an English phrase followed by a Hebrew phrase,
/// in a [TextDirection.ltr] context the English phrase will be on the left
/// and the Hebrew phrase to its right, while in a [TextDirection.rtl]
/// context, the English phrase will be on the right and the Hebrew phrase on
/// its left.
///
/// Defaults to the ambient [Directionality], if any.
final TextDirection? textDirection;
/// Used to select a font when the same Unicode character can
/// be rendered differently, depending on the locale.
///
/// It's rarely necessary to set this property. By default its value
/// is inherited from the enclosing app with `Localizations.localeOf(context)`.
///
/// See [RenderParagraph.locale] for more information.
final Locale? locale;
/// Whether the text should break at soft line breaks.
///
/// If false, the glyphs in the text will be positioned as if there was unlimited horizontal space.
final bool? softWrap;
/// How visual overflow should be handled.
///
/// If this is null [TextStyle.overflow] will be used, otherwise the value
/// from the nearest [DefaultTextStyle] ancestor will be used.
final TextOverflow? overflow;
final TextScaler? textScaler;
/// An optional maximum number of lines for the text to span, wrapping if necessary.
/// If the text exceeds the given number of lines, it will be truncated according
/// to [overflow].
///
/// If this is 1, text will not wrap. Otherwise, text will be wrapped at the
/// edge of the box.
///
/// If this is null, but there is an ambient [DefaultTextStyle] that specifies
/// an explicit number for its [DefaultTextStyle.maxLines], then the
/// [DefaultTextStyle] value will take precedence. You can use a [RichText]
/// widget directly to entirely override the [DefaultTextStyle].
final int? maxLines;
/// An alternative semantics label for this text.
///
/// If present, the semantics of this widget will contain this value instead
/// of the actual text. This will overwrite any of the semantics labels applied
/// directly to the [TextSpan]s.
///
/// This is useful for replacing abbreviations or shorthands with the full
/// text value:
///
/// ```dart
/// const Text(r'$$', semanticsLabel: 'Double dollars')
/// ```
final String? semanticsLabel;
/// A unique identifier for the semantics node for this widget.
///
/// This is useful for cases where the text widget needs to have a uniquely
/// identifiable ID that is recognized through the automation tools without
/// having a dependency on the actual content of the text that can possibly be
/// dynamic in nature.
final String? semanticsIdentifier;
final TextWidthBasis? textWidthBasis;
final TextHeightBehavior? textHeightBehavior;
/// The color to use when painting the selection.
///
/// This is ignored if [SelectionContainer.maybeOf] returns null
/// in the [BuildContext] of the [Text] widget.
///
/// If null, the ambient [DefaultSelectionStyle] is used (if any); failing
/// that, the selection color defaults to [DefaultSelectionStyle.defaultColor]
/// (semi-transparent grey).
final Color? selectionColor;
/// Show the amount in the base unit format of [Money.currency]
///
/// If null, the displayAmountsInSatoshi setting is used.
final bool? useBaseUnit;
/// A limit for the display of the fractional digits of the amount
final int fractionalDigits;
/// Hide the amount
///
/// If null, the value from the balanceDisplayMode setting is used.
final bool? isHiddenAmount;
/// Show the currency symbol
final bool showSymbol;
/// Prefix the amount with the currency symbol if [showSymbol] is true
final bool withSymbolPrefix;
/// Trim the zeros at the end of an amount
final bool trimZeros;
@override
Widget build(BuildContext context) => BlocBuilder<MoneySettingsCubit, MoneySettingsState>(
builder: (context, state) => Text(
isHiddenAmount ?? state.isHidden
? "●●●●●●"
: showSymbol
? amount.toLocalStringWithSymbol(
fractionalDigits: fractionalDigits,
trimZeros: trimZeros,
useBaseUnit: useBaseUnit ?? state.useBaseUnit(amount.currency),
withSymbolPrefix: withSymbolPrefix,
locale: (locale ?? Localizations.localeOf(context)).toString(),
)
: amount.toLocalStringWithPrecision(
fractionalDigits: fractionalDigits,
trimZeros: trimZeros,
useBaseUnit: useBaseUnit ?? state.useBaseUnit(amount.currency),
locale: (locale ?? Localizations.localeOf(context)).toString(),
),
style: style,
strutStyle: strutStyle,
textAlign: textAlign,
textDirection: textDirection,
locale: locale,
softWrap: softWrap,
overflow: overflow,
textScaler: textScaler,
maxLines: maxLines,
semanticsLabel: isHiddenAmount ?? state.isHidden
? (semanticsLabel ?? S.of(context).amount_hidden)
: semanticsLabel,
semanticsIdentifier: semanticsIdentifier,
textWidthBasis: textWidthBasis,
textHeightBehavior: textHeightBehavior,
selectionColor: selectionColor,
),
);
}
@@ -0,0 +1,63 @@
import "dart:async";
import "package:hash_wallet/new-ui/widgets/money/money_settings_cubit.dart";
import "package:cw_core/currency.dart";
import "package:flutter/widgets.dart";
import "package:flutter_bloc/flutter_bloc.dart";
/// Read-only view of the money display settings for the current subtree.
///
/// ```dart
/// final useBaseUnit = BaseUnit.useBaseUnitOf(context, CryptoCurrency.btc);
/// ```
class BaseUnit extends InheritedModel<Currency> {
const BaseUnit({required this.state, required super.child});
final MoneySettingsState state;
static bool useBaseUnitOf(BuildContext context, Currency currency) =>
InheritedModel.inheritFrom<BaseUnit>(context, aspect: currency)!.state.useBaseUnit(currency);
static String getSymbolOf(BuildContext context, Currency currency) =>
InheritedModel.inheritFrom<BaseUnit>(context, aspect: currency)!.state.getSymbol(currency);
@override
bool updateShouldNotify(BaseUnit old) =>
state.bitcoinAmountDisplayMode != old.state.bitcoinAmountDisplayMode;
@override
bool updateShouldNotifyDependent(BaseUnit old, Set<Currency> aspects) => aspects.any((c) =>
state.useBaseUnit(c) != old.state.useBaseUnit(c) ||
state.getSymbol(c) != old.state.getSymbol(c));
}
class BaseUnitScope extends StatefulWidget {
const BaseUnitScope({required this.child, super.key});
final Widget child;
@override
State<BaseUnitScope> createState() => _BaseUnitScopeState();
}
class _BaseUnitScopeState extends State<BaseUnitScope> {
late MoneySettingsState _state;
late final StreamSubscription<MoneySettingsState> _sub;
@override
void initState() {
super.initState();
final cubit = context.read<MoneySettingsCubit>();
_state = cubit.state;
_sub = cubit.stream.listen((s) => setState(() => _state = s));
}
@override
void dispose() {
_sub.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) => BaseUnit(state: _state, child: widget.child);
}
@@ -0,0 +1,57 @@
import "package:hash_wallet/new-ui/widgets/new_primary_button.dart";
import "package:flutter/material.dart";
import "package:flutter_svg/flutter_svg.dart";
class NewFuturePrimaryButton extends StatefulWidget {
const NewFuturePrimaryButton({
required this.onPressed,
required this.text,
required this.color,
required this.textColor,
this.borderColor = Colors.transparent,
this.disabled = false,
this.image,
super.key,
});
final Future<void> Function() onPressed;
final bool disabled;
final SvgPicture? image;
final Color color;
final Color textColor;
final Color borderColor;
final String text;
@override
State<StatefulWidget> createState() => _NewFuturePrimaryButtonState();
}
class _NewFuturePrimaryButtonState extends State<NewFuturePrimaryButton> {
bool isLoading = false;
Future<void> _onPressed() async {
if (isLoading) {
return;
}
setState(() => isLoading = true);
try {
await widget.onPressed.call();
} finally {
if (mounted) {
setState(() => isLoading = false);
}
}
}
@override
Widget build(BuildContext context) => NewPrimaryButton(
onPressed: _onPressed,
image: widget.image,
text: widget.text,
color: widget.color,
textColor: widget.textColor,
isLoading: isLoading,
borderColor: widget.borderColor,
disabled: widget.disabled,
);
}
+36 -39
View File
@@ -1,14 +1,13 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
import "package:flutter/cupertino.dart";
import "package:flutter/material.dart";
class NewPrimaryButton extends StatelessWidget {
const NewPrimaryButton({
required this.onPressed,
this.image,
required this.text,
required this.color,
required this.textColor,
this.image,
this.isLoading = false,
this.borderColor = Colors.transparent,
this.disabled = false,
@@ -18,48 +17,46 @@ class NewPrimaryButton extends StatelessWidget {
final VoidCallback onPressed;
final bool isLoading;
final bool disabled;
final SvgPicture? image;
final Widget? image;
final Color color;
final Color textColor;
final Color borderColor;
final String text;
@override
Widget build(BuildContext context) {
return SizedBox(
height: 52.0,
child: TextButton(
onPressed: disabled ? null : onPressed,
style: ButtonStyle(
backgroundColor: WidgetStateProperty.all(
disabled ? color.withAlpha(128) : color,
),
shape: WidgetStateProperty.all<RoundedSuperellipseBorder>(
RoundedSuperellipseBorder(
borderRadius: BorderRadius.circular(18.0),
Widget build(BuildContext context) => SizedBox(
height: 52,
child: TextButton(
onPressed: disabled ? null : onPressed,
style: ButtonStyle(
backgroundColor: WidgetStateProperty.all(
disabled ? color.withAlpha(128) : color,
),
shape: WidgetStateProperty.all<RoundedSuperellipseBorder>(
RoundedSuperellipseBorder(
borderRadius: BorderRadius.circular(18),
),
),
),
child: Center(
child: isLoading
? const CupertinoActivityIndicator()
: Row(
spacing: 10,
mainAxisSize: MainAxisSize.min,
children: <Widget>[
if (image != null) image!,
Text(
text,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontSize: 14,
fontWeight: FontWeight.w600,
color: textColor,
),
),
],
),
),
),
child: Center(
child: isLoading
? CupertinoActivityIndicator()
: Row(
spacing: 10,
mainAxisSize: MainAxisSize.min,
children: <Widget>[
if (image != null) image!,
Text(
text,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontSize: 14,
fontWeight: FontWeight.w600,
color: textColor,
),
)
],
),
),
),
);
}
);
}
+3
View File
@@ -1,5 +1,6 @@
import 'dart:io';
import 'package:hash_wallet/generated/i18n.dart';
import 'package:hash_wallet/new-ui/widgets/new_primary_button.dart';
import 'package:hash_wallet/new-ui/widgets/receive_page/receive_top_bar.dart';
import 'package:hash_wallet/src/widgets/hash_image_widget.dart';
@@ -77,6 +78,7 @@ class _NewPickerState<Item> extends State<NewPicker<Item>> {
ModalTopBar(
title: widget.title,
leadingIcon: Icon(Icons.arrow_back_ios_new),
leadingSemanticLabel: S.of(context).seed_alert_back,
onLeadingPressed: Navigator.of(context).maybePop,
),
Padding(
@@ -387,6 +389,7 @@ class _PickerSliderPageState<Item> extends State<PickerSliderPage<Item>> {
ModalTopBar(
title: widget.title,
leadingIcon: Icon(Icons.arrow_back_ios_new),
leadingSemanticLabel: S.of(context).seed_alert_back,
onLeadingPressed: Navigator.of(context).pop,
),
Expanded(
@@ -4,6 +4,7 @@ import 'package:hash_wallet/new-ui/widgets/receive_page/receive_top_bar.dart';
import 'package:hash_wallet/src/widgets/new_list_row/new_list_section.dart';
import 'package:cw_core/payment_uris.dart';
import 'package:flutter/material.dart';
import 'package:flutter/semantics.dart';
import 'package:flutter/services.dart';
class PayjoinCopyModal extends StatelessWidget {
@@ -25,6 +26,7 @@ class PayjoinCopyModal extends StatelessWidget {
ModalTopBar(
title: S.of(context).select_address_to_copy,
leadingIcon: Icon(Icons.close),
leadingSemanticLabel: S.of(context).close,
onLeadingPressed: Navigator.of(context).pop),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
@@ -32,7 +34,7 @@ class PayjoinCopyModal extends StatelessWidget {
"": [
ListItemRegularRow(
keyValue: "btc",
label: "Standard",
label: S.of(context).standard,
iconPath: "assets/new-ui/pjmodal_btc.svg",
onTap: () {
Clipboard.setData(
@@ -41,6 +43,7 @@ class PayjoinCopyModal extends StatelessWidget {
? BitcoinURI(amount: uri.amount, address: uri.address).toString()
: uri.address),
);
_announceCopied(context);
Navigator.of(context).pop();
}),
ListItemRegularRow(
@@ -51,6 +54,7 @@ class PayjoinCopyModal extends StatelessWidget {
Clipboard.setData(
ClipboardData(text: uri.toString()),
);
_announceCopied(context);
Navigator.of(context).pop();
})
]
@@ -62,4 +66,15 @@ class PayjoinCopyModal extends StatelessWidget {
),
);
}
// Copying pops this sheet right away, so no widget survives to carry a
// semantics state change: this path still needs a direct announcement. It is
// skipped on platforms that deprecate announcements (android, where they
// clear TalkBack's speech queue and the system shows its own clipboard
// confirmation anyway).
void _announceCopied(BuildContext context) {
if (!MediaQuery.supportsAnnounceOf(context)) return;
SemanticsService.sendAnnouncement(
View.of(context), S.of(context).copied, Directionality.of(context));
}
}
@@ -42,52 +42,64 @@ class ReceiveAddressTypeDisplay extends StatelessWidget {
iconPath = "assets/new-ui/address-type-picker-icons/litecoin.svg";
}
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => _showPicker(context),
child: Row(
key: ValueKey("$text$largeQrMode"),
mainAxisSize: MainAxisSize.max,
mainAxisAlignment: MainAxisAlignment.center,
spacing: 12.0,
children: [
if (iconPath != null)
HashImageWidget(
imageUrl: iconPath,
width: 24,
height: 24,
colorFilter: ColorFilter.mode(
Theme.of(context).colorScheme.primary,
BlendMode.srcIn,
),
),
Text(
text,
style: TextStyle(
fontSize: 16,
color: Theme.of(context).colorScheme.primary,
),
),
if (!largeQrMode)
Container(
width: 24,
height: 24,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainer,
borderRadius: BorderRadius.circular(999999),
),
child: IconButton(
padding: EdgeInsets.zero,
constraints: BoxConstraints(),
onPressed: () => _showPicker(context),
icon: (Icon(
// The row and the chevron button trigger the same picker, so only the
// row is exposed and the chevron is treated as decoration.
return MergeSemantics(
child: Semantics(
button: true,
label: S.of(context).address_type,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => _showPicker(context),
child: Row(
key: ValueKey("$text$largeQrMode"),
mainAxisSize: MainAxisSize.max,
mainAxisAlignment: MainAxisAlignment.center,
spacing: 12.0,
children: [
if (iconPath != null)
ExcludeSemantics(
child: HashImageWidget(
imageUrl: iconPath,
width: 24,
height: 24,
colorFilter: ColorFilter.mode(
Theme.of(context).colorScheme.primary,
BlendMode.srcIn,
),
),
),
Text(
text,
style: TextStyle(
fontSize: 16,
color: Theme.of(context).colorScheme.primary,
size: 20,
Icons.keyboard_arrow_down,
)),
),
),
),
],
if (!largeQrMode)
ExcludeSemantics(
child: Container(
width: 24,
height: 24,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainer,
borderRadius: BorderRadius.circular(999999),
),
child: IconButton(
padding: EdgeInsets.zero,
constraints: BoxConstraints(),
onPressed: () => _showPicker(context),
icon: (Icon(
color: Theme.of(context).colorScheme.primary,
size: 20,
Icons.keyboard_arrow_down,
)),
),
),
),
],
),
),
),
);
},
@@ -6,6 +6,7 @@ import 'package:hash_wallet/src/widgets/section_divider.dart';
import 'package:hash_wallet/view_model/dashboard/receive_option_view_model.dart';
import 'package:cw_core/receive_page_option.dart';
import 'package:flutter/material.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:modal_bottom_sheet/modal_bottom_sheet.dart';
@@ -34,6 +35,7 @@ class _ReceiveAddressTypeSelectorState extends State<ReceiveAddressTypeSelector>
@override
Widget build(BuildContext context) {
return Observer(builder: (final _) {
final commonOptions = widget.receiveOptionViewModel.options
.where((element) =>
element.isCommon ||
@@ -71,6 +73,7 @@ class _ReceiveAddressTypeSelectorState extends State<ReceiveAddressTypeSelector>
ModalTopBar(
title: S.of(context).address_type,
leadingIcon: Icon(Icons.close),
leadingSemanticLabel: S.of(context).close,
onLeadingPressed: Navigator.of(context).pop,
onTrailingPressed: () {}),
SizedBox(height: 24),
@@ -131,38 +134,49 @@ class _ReceiveAddressTypeSelectorState extends State<ReceiveAddressTypeSelector>
borderRadius: BorderRadius.vertical(
top: Radius.circular(20),
bottom: _otherOptionsExpanded ? Radius.zero : Radius.circular(20)),
child: InkWell(
highlightColor: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.vertical(
top: Radius.circular(20),
bottom: !_otherOptionsExpanded ? Radius.zero : Radius.circular(20)),
onTap: () {
setState(() {
_otherOptionsExpanded = !_otherOptionsExpanded;
});
},
child: Container(
height: 64.0,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 18.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
S.of(context).more_options,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w400,
color: Theme.of(context).colorScheme.primary),
child: MergeSemantics(
child: Semantics(
button: true,
expanded: _otherOptionsExpanded,
child: InkWell(
highlightColor:
Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.vertical(
top: Radius.circular(20),
bottom: !_otherOptionsExpanded
? Radius.zero
: Radius.circular(20)),
onTap: () {
setState(() {
_otherOptionsExpanded = !_otherOptionsExpanded;
});
},
child: Container(
height: 64.0,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 18.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
S.of(context).more_options,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w400,
color: Theme.of(context).colorScheme.primary),
),
ExcludeSemantics(
child: AnimatedRotation(
duration: ReceiveAddressTypeSelector
.otherOptionsExpandDuration,
turns: _otherOptionsExpanded ? 0.0 : 0.5,
curve: Curves.easeOut,
child: HashImageWidget(
imageUrl: "assets/new-ui/dropdown_arrow.svg")),
)
],
),
AnimatedRotation(
duration:
ReceiveAddressTypeSelector.otherOptionsExpandDuration,
turns: _otherOptionsExpanded ? 0.0 : 0.5,
curve: Curves.easeOut,
child: HashImageWidget(
imageUrl: "assets/new-ui/dropdown_arrow.svg"))
],
),
),
),
),
@@ -177,40 +191,45 @@ class _ReceiveAddressTypeSelectorState extends State<ReceiveAddressTypeSelector>
duration: ReceiveAddressTypeSelector.otherOptionsExpandDuration,
curve: Curves.easeOut,
alignment: Alignment.topCenter,
child: Container(
height: _otherOptionsExpanded ? null : 0,
child: ListView.separated(
shrinkWrap: true,
padding: EdgeInsets.zero,
physics: NeverScrollableScrollPhysics(),
itemCount: otherOptions.length,
itemBuilder: (context, index) {
final opt = otherOptions[index];
// Collapsed to zero height: keep the options out of
// the semantics tree so they cannot be focused.
child: ExcludeSemantics(
excluding: !_otherOptionsExpanded,
child: Container(
height: _otherOptionsExpanded ? null : 0,
child: ListView.separated(
shrinkWrap: true,
padding: EdgeInsets.zero,
physics: NeverScrollableScrollPhysics(),
itemCount: otherOptions.length,
itemBuilder: (context, index) {
final opt = otherOptions[index];
return ReceiveAddressTypeRow(
option: opt,
roundedTop: false,
roundedBottom: index == otherOptions.length - 1,
selected:
widget.receiveOptionViewModel.selectedReceiveOption == opt,
onItemTap: () {
Navigator.of(context).pop(opt);
},
receiveOptionViewModel: widget.receiveOptionViewModel,
);
},
separatorBuilder: (context, index) {
if ((widget.receiveOptionViewModel.selectedReceiveOption ==
otherOptions[index]) ||
(index != otherOptions.length - 1 &&
widget.receiveOptionViewModel.selectedReceiveOption ==
otherOptions[index + 1])) return Container();
return ReceiveAddressTypeRow(
option: opt,
roundedTop: false,
roundedBottom: index == otherOptions.length - 1,
selected:
widget.receiveOptionViewModel.selectedReceiveOption == opt,
onItemTap: () {
Navigator.of(context).pop(opt);
},
receiveOptionViewModel: widget.receiveOptionViewModel,
);
},
separatorBuilder: (context, index) {
if ((widget.receiveOptionViewModel.selectedReceiveOption ==
otherOptions[index]) ||
(index != otherOptions.length - 1 &&
widget.receiveOptionViewModel.selectedReceiveOption ==
otherOptions[index + 1])) return Container();
return Padding(
padding: const EdgeInsets.only(left: 44.0, right: 36.0),
child: HorizontalSectionDivider(),
);
},
return Padding(
padding: const EdgeInsets.only(left: 44.0, right: 36.0),
child: HorizontalSectionDivider(),
);
},
),
),
),
),
@@ -223,6 +242,7 @@ class _ReceiveAddressTypeSelectorState extends State<ReceiveAddressTypeSelector>
],
)),
);
});
}
}
@@ -260,65 +280,75 @@ class ReceiveAddressTypeRow extends StatelessWidget {
top: roundedTop ? Radius.circular(20) : Radius.zero,
bottom: roundedBottom ? Radius.circular(20) : Radius.zero,
),
child: InkWell(
onTap: onItemTap,
child: Container(
height: rowHeight,
decoration: BoxDecoration(
color: selected
? Theme.of(context).colorScheme.surfaceContainerHigh
: Colors.transparent,
borderRadius: BorderRadius.vertical(
top: roundedTop ? Radius.circular(20) : Radius.zero,
bottom: roundedBottom ? Radius.circular(20) : Radius.zero,
)),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 10.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
mainAxisSize: MainAxisSize.max,
children: [
Row(
// The row itself carries the selection state; the checkbox next to it is
// only a visual echo of it and must not be announced separately.
child: MergeSemantics(
child: Semantics(
selected: selected,
inMutuallyExclusiveGroup: true,
child: InkWell(
onTap: onItemTap,
child: Container(
height: rowHeight,
decoration: BoxDecoration(
color: selected
? Theme.of(context).colorScheme.surfaceContainerHigh
: Colors.transparent,
borderRadius: BorderRadius.vertical(
top: roundedTop ? Radius.circular(20) : Radius.zero,
bottom: roundedBottom ? Radius.circular(20) : Radius.zero,
)),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 10.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
mainAxisSize: MainAxisSize.max,
children: [
if (iconPath != null)
HashImageWidget(
imageUrl: iconPath!,
width: iconSize,
height: iconSize,
colorFilter: ColorFilter.mode(
Theme.of(context).colorScheme.onSurfaceVariant, BlendMode.srcIn),
)
else
Container(width: iconSize, height: iconSize),
Padding(
padding: const EdgeInsets.all(10.0),
child: Column(
mainAxisSize: MainAxisSize.max,
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
option.value,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
color: Theme.of(context).colorScheme.onSurface),
Row(
children: [
if (iconPath != null)
ExcludeSemantics(
child: HashImageWidget(
imageUrl: iconPath!,
width: iconSize,
height: iconSize,
colorFilter: ColorFilter.mode(
Theme.of(context).colorScheme.onSurfaceVariant, BlendMode.srcIn),
),
)
else
Container(width: iconSize, height: iconSize),
Padding(
padding: const EdgeInsets.all(10.0),
child: Column(
mainAxisSize: MainAxisSize.max,
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
option.value,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
color: Theme.of(context).colorScheme.onSurface),
),
if (option.description != null)
Text(
option.description!,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w400,
color: Theme.of(context).colorScheme.onSurfaceVariant),
)
],
),
if (option.description != null)
Text(
option.description!,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w400,
color: Theme.of(context).colorScheme.onSurfaceVariant),
)
],
),
)
)
],
),
ExcludeSemantics(child: RoundedCheckbox(value: selected))
],
),
RoundedCheckbox(value: selected)
],
),
),
),
),
@@ -11,6 +11,7 @@ class ReceiveAddressWidget extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Padding(
key: const ValueKey("receive_page_address_key"),
padding: const EdgeInsets.symmetric(horizontal: 50.0),
child: Observer(
builder: (_) => AddressFormatter.buildSegmentedAddress(
@@ -22,50 +22,54 @@ class ReceiveAmountDisplay extends StatelessWidget {
curve: Curves.easeOutCubic,
heightFactor: largeQrMode || walletAddressListViewModel.displayAmount.isEmpty ? 0 : 1,
alignment: Alignment.topCenter,
child: Container(
width: double.infinity,
child: Row(
spacing: 4,
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
color: Theme.of(context).colorScheme.surfaceContainer),
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Row(
spacing: 8.0,
children: [
Text(
walletAddressListViewModel.displayAmount,
style: TextStyle(
color: Theme.of(context).colorScheme.primary,
fontSize: 16,
fontWeight: FontWeight.w500),
),
Text(walletAddressListViewModel.cryptoCurrencySymbol,
// Amount, currency symbol and fiat equivalent describe one value, so
// they are announced as a single node.
child: MergeSemantics(
child: Container(
width: double.infinity,
child: Row(
spacing: 4,
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
color: Theme.of(context).colorScheme.surfaceContainer),
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Row(
spacing: 8.0,
children: [
Text(
walletAddressListViewModel.displayAmount,
style: TextStyle(
color: Theme.of(context).colorScheme.primary,
fontSize: 16,
fontWeight: FontWeight.w500))
],
fontWeight: FontWeight.w500),
),
Text(walletAddressListViewModel.cryptoCurrencySymbol,
style: TextStyle(
color: Theme.of(context).colorScheme.primary,
fontSize: 16,
fontWeight: FontWeight.w500))
],
),
),
),
),
if (!walletAddressListViewModel.isFiatDisabled)
Padding(
padding: const EdgeInsets.all(8.0),
child: Text(
_getFiatAmount(),
style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant,
fontSize: 16,
fontWeight: FontWeight.w500),
),
)
],
if (!walletAddressListViewModel.isFiatDisabled)
Padding(
padding: const EdgeInsets.all(8.0),
child: Text(
_getFiatAmount(),
style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant,
fontSize: 16,
fontWeight: FontWeight.w500),
),
)
],
),
),
),
),
@@ -1,15 +1,17 @@
import 'package:hash_wallet/entities/fiat_currency.dart';
import 'package:hash_wallet/generated/i18n.dart';
import 'package:hash_wallet/new-ui/widgets/currency_picker/currency_picker_args.dart';
import 'package:hash_wallet/new-ui/widgets/currency_picker/currency_picker_sheet.dart';
import 'package:hash_wallet/new-ui/widgets/currency_picker/fiat_currency_picker_sheet.dart';
import 'package:hash_wallet/new-ui/widgets/coins_page/token_image_widget.dart';
import 'package:hash_wallet/new-ui/widgets/new_primary_button.dart';
import 'package:hash_wallet/new-ui/widgets/receive_page/receive_top_bar.dart';
import 'package:hash_wallet/src/screens/exchange/widgets/currency_picker.dart';
import 'package:hash_wallet/src/widgets/hash_image_widget.dart';
import 'package:hash_wallet/utils/show_pop_up.dart';
import 'package:hash_wallet/utils/decimal_input_formatter.dart';
import 'package:hash_wallet/view_model/wallet_address_list/wallet_address_list_view_model.dart';
import 'package:cw_core/crypto_currency.dart';
import 'package:flutter/material.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:flutter_svg/svg.dart';
class ReceiveAmountModal extends StatefulWidget {
const ReceiveAmountModal(
@@ -54,6 +56,7 @@ class _ReceiveAmountModalState extends State<ReceiveAmountModal> {
onLeadingPressed: Navigator.of(context).pop,
onTrailingPressed: () {},
leadingIcon: Icon(Icons.close),
leadingSemanticLabel: S.of(context).close,
),
Padding(
padding: const EdgeInsets.all(18.0),
@@ -63,54 +66,68 @@ class _ReceiveAmountModalState extends State<ReceiveAmountModal> {
spacing: 12,
children: [
if (widget.walletAddressListViewModel.hasTokensList) ...[
Text("Token"),
GestureDetector(
onTap: () {
_presentTokenCurrencyPicker(context);
},
child: Observer(
builder: (_) => Container(
height: 60,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(16),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Row(
mainAxisSize: MainAxisSize.max,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
spacing: 8,
children: [
Image.asset(
widget.walletAddressListViewModel.tokenCurrency
?.iconPath ??
widget.walletAddressListViewModel.currencies.first
.iconPath ??
"",
width: 32,
height: 32),
Text((widget.walletAddressListViewModel.tokenCurrency ??
widget.walletAddressListViewModel.currencies.first
as CryptoCurrency)
.title
.toUpperCase())
],
),
RotatedBox(
quarterTurns: 2,
child: HashImageWidget(
imageUrl: "assets/new-ui/dropdown_arrow.svg"))
],
// The caption is reused as the picker's semantics label,
// so it must not be announced as a separate node.
ExcludeSemantics(child: Text(S.of(context).token)),
MergeSemantics(
child: Semantics(
button: true,
label: S.of(context).select_token,
child: GestureDetector(
onTap: () {
_presentTokenCurrencyPicker(context);
},
child: Observer(
builder: (_) => Container(
height: 60,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(16),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Row(
mainAxisSize: MainAxisSize.max,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
spacing: 8,
children: [
ExcludeSemantics(
child: TokenImageWidget(
imageUrl: widget.walletAddressListViewModel
.tokenCurrency?.iconPath ??
widget.walletAddressListViewModel.currencies.first
.iconPath ??
"",
size: 32,
),
),
Text((widget.walletAddressListViewModel.tokenCurrency ??
widget.walletAddressListViewModel.currencies.first
as CryptoCurrency)
.title
.toUpperCase())
],
),
ExcludeSemantics(
child: RotatedBox(
quarterTurns: 2,
child: HashImageWidget(
imageUrl: "assets/new-ui/dropdown_arrow.svg")),
)
],
),
),
),
),
)),
),
),
),
],
SizedBox(),
Text(S.of(context).amount),
// The caption is reused as the field's semantics label.
ExcludeSemantics(child: Text(S.of(context).amount)),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
@@ -130,66 +147,91 @@ class _ReceiveAmountModalState extends State<ReceiveAmountModal> {
width: 2,
),
),
child: TextField(
textAlign: TextAlign.left,
textAlignVertical: TextAlignVertical.center,
controller: _amountController,
keyboardType: TextInputType.numberWithOptions(
signed: false,
decimal: !widget.walletAddressListViewModel.useSatoshi),
decoration: InputDecoration(
hint: Text(
widget.walletAddressListViewModel.useSatoshi ? "0" : "0.00000000",
child: MergeSemantics(
child: Semantics(
label: S.of(context).amount,
child: TextField(
textAlign: TextAlign.left,
style: TextStyle(
fontSize: 16,
color: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.5),
textAlignVertical: TextAlignVertical.center,
controller: _amountController,
keyboardType: TextInputType.numberWithOptions(
signed: false,
decimal:
widget.walletAddressListViewModel.selectedCurrencyDecimals >
0,
),
inputFormatters: [
DecimalInputFormatter(
maxDecimals:
widget.walletAddressListViewModel.selectedCurrencyDecimals,
),
],
decoration: InputDecoration(
hint: Text(
widget.walletAddressListViewModel.useSatoshi
? "0"
: "0.00000000",
textAlign: TextAlign.left,
style: TextStyle(
fontSize: 16,
color: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.5),
),
),
border: InputBorder.none,
filled: true,
fillColor: Colors.transparent,
),
style:
TextStyle(color: Theme.of(context).colorScheme.onSurface),
),
border: InputBorder.none,
filled: true,
fillColor: Colors.transparent,
),
style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
),
),
),
Expanded(
flex: 25,
child: GestureDetector(
onTap: () {
_presentFiatCurrencyPicker(context);
},
child: Container(
height: 60,
decoration: BoxDecoration(
borderRadius: BorderRadius.only(
topLeft: Radius.circular(0),
bottomLeft: Radius.circular(0),
topRight: Radius.circular(18),
bottomRight: Radius.circular(18),
),
color: Theme.of(context).colorScheme.surfaceContainerHigh,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
spacing: 4.0,
children: [
Observer(
builder: (_) => Text(
widget.walletAddressListViewModel.selectedCurrencySymbol,
style: TextStyle(
color: Theme.of(context).colorScheme.onSurface,
),
child: MergeSemantics(
child: Semantics(
button: true,
label: S.of(context).select_fiat_currency_title,
child: GestureDetector(
onTap: () {
_presentFiatCurrencyPicker(context);
},
child: Container(
height: 60,
decoration: BoxDecoration(
borderRadius: BorderRadius.only(
topLeft: Radius.circular(0),
bottomLeft: Radius.circular(0),
topRight: Radius.circular(18),
bottomRight: Radius.circular(18),
),
color: Theme.of(context).colorScheme.surfaceContainerHigh,
),
Icon(
Icons.keyboard_arrow_down,
color: Theme.of(context).colorScheme.primary,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
spacing: 4.0,
children: [
Observer(
builder: (_) => Text(
widget.walletAddressListViewModel.selectedCurrencySymbol,
style: TextStyle(
color: Theme.of(context).colorScheme.onSurface,
),
),
),
ExcludeSemantics(
child: Icon(
Icons.keyboard_arrow_down,
color: Theme.of(context).colorScheme.primary,
),
),
],
),
],
),
),
),
),
@@ -218,35 +260,30 @@ class _ReceiveAmountModalState extends State<ReceiveAmountModal> {
);
}
void _presentTokenCurrencyPicker(BuildContext context) async {
await showPopUp(
builder: (_) => CurrencyPicker(
selectedAtIndex: widget.walletAddressListViewModel.tokenCurrencyIndex,
items: widget.walletAddressListViewModel.tokenCurrencies,
hintText: S.of(context).search_currency,
onItemSelected: widget.walletAddressListViewModel.setTokenCurrency,
),
void _presentTokenCurrencyPicker(BuildContext context) {
final items =
widget.walletAddressListViewModel.tokenCurrencies.whereType<CryptoCurrency>().toList();
CurrencyPickerSheet.show(
context: context,
args: CurrencyPickerArgs(
items: items,
selected: widget.walletAddressListViewModel.tokenCurrency,
onSelected: widget.walletAddressListViewModel.setTokenCurrency,
symbolResolver: widget.walletAddressListViewModel.amountParsingProxy.getCryptoSymbol,
),
);
}
void _presentFiatCurrencyPicker(BuildContext context) async {
final currs = widget.walletAddressListViewModel.currencies.toList();
// the first options should be the crypto and the default fiat currency
currs.sort((a, b) {
if (a == widget.walletAddressListViewModel.fiatCurrency || a is CryptoCurrency) return -1;
if (b == widget.walletAddressListViewModel.fiatCurrency || a is CryptoCurrency) return 1;
return 0;
});
await showPopUp(
builder: (_) => CurrencyPicker(
selectedAtIndex: currs.indexOf(widget.walletAddressListViewModel.selectedCurrency),
items: currs,
hintText: S.of(context).search_currency,
onItemSelected: widget.walletAddressListViewModel.selectCurrency,
),
void _presentFiatCurrencyPicker(BuildContext context) {
final selected = widget.walletAddressListViewModel.selectedCurrency;
final cryptoOption =
widget.walletAddressListViewModel.currencies.whereType<CryptoCurrency>().firstOrNull;
FiatCurrencyPickerSheet.show(
context: context,
selected: selected,
cryptoOption: cryptoOption,
onSelected: widget.walletAddressListViewModel.selectCurrency,
onCryptoSelected: widget.walletAddressListViewModel.selectCurrency,
);
}
}
@@ -1,21 +1,11 @@
import 'dart:io';
import 'package:hash_wallet/generated/i18n.dart';
import 'package:hash_wallet/new-ui/widgets/modern_button.dart';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:flutter/material.dart';
import "package:hash_wallet/generated/i18n.dart";
import "package:hash_wallet/new-ui/widgets/copy_wrapper.dart";
import "package:hash_wallet/new-ui/widgets/modern_button.dart";
import "package:flutter/material.dart";
import "package:flutter/services.dart";
class ReceiveBottomButtons extends StatefulWidget {
final bool largeQrMode;
final VoidCallback onCopyButtonPressed;
final VoidCallback onAmountButtonPressed;
final VoidCallback onLabelButtonPressed;
final VoidCallback onAccountsButtonPressed;
final bool showLabelButton;
final bool showAccountsButton;
const ReceiveBottomButtons({
super.key,
required this.largeQrMode,
required this.onCopyButtonPressed,
required this.onAccountsButtonPressed,
@@ -23,25 +13,24 @@ class ReceiveBottomButtons extends StatefulWidget {
required this.onLabelButtonPressed,
required this.showLabelButton,
required this.showAccountsButton,
required this.copyData,
super.key,
});
final bool largeQrMode;
final ClipboardData? copyData;
final VoidCallback onCopyButtonPressed;
final VoidCallback onAmountButtonPressed;
final VoidCallback onLabelButtonPressed;
final VoidCallback onAccountsButtonPressed;
final bool showLabelButton;
final bool showAccountsButton;
@override
State<ReceiveBottomButtons> createState() => _ReceiveBottomButtonsState();
}
class _ReceiveBottomButtonsState extends State<ReceiveBottomButtons> {
bool copied = false;
void handleCopy() async {
widget.onCopyButtonPressed();
if (await shouldShowCopied()) {
setState(() => copied = true);
Future.delayed(const Duration(milliseconds: 1200), () {
if (mounted) setState(() => copied = false);
});
}
}
@override
Widget build(BuildContext context) {
final double targetOpacity = widget.largeQrMode ? 0 : 1;
@@ -57,49 +46,57 @@ class _ReceiveBottomButtonsState extends State<ReceiveBottomButtons> {
opacity: targetOpacity,
curve: Curves.easeOut,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 32.0),
padding: const EdgeInsets.symmetric(horizontal: 32),
child: Row(
mainAxisSize: MainAxisSize.max,
mainAxisAlignment: MainAxisAlignment.center,
spacing: 16,
children: [
AnimatedSwitcher(
duration: Duration(milliseconds: 200),
child: ModernButton.svg(
key: ValueKey(copied),
size: 60,
iconSize: 32,
svgPath: "assets/new-ui/copy.svg",
onPressed: handleCopy,
label: copied ? S.of(context).copied : S.of(context).copy,
iconColor: copied
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.surfaceContainer,
backgroundColor: copied
? Theme.of(context).colorScheme.surfaceContainerHighest
: Theme.of(context).colorScheme.primary,
// The button itself is the only control: it copies when there
// is data to copy, otherwise it opens the payjoin copy modal.
CopyWrapper(
data: widget.copyData,
controlBuilder: (context, copied, onCopy) => AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
child: ModernButton.svg(
key: ValueKey(copied),
size: 60,
iconSize: 32,
svgPath: "assets/new-ui/copy.svg",
onPressed: onCopy ?? widget.onCopyButtonPressed,
label: copied ? S.of(context).copied : S.of(context).copy,
iconColor: copied
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.surfaceContainer,
backgroundColor: copied
? Theme.of(context).colorScheme.surfaceContainerHighest
: Theme.of(context).colorScheme.primary,
),
),
),
ModernButton.svg(
size: 60,
iconSize: 32,
svgPath: "assets/new-ui/set-amount.svg",
onPressed: widget.onAmountButtonPressed,
label: S.of(context).set_amount),
size: 60,
iconSize: 32,
svgPath: "assets/new-ui/set-amount.svg",
onPressed: widget.onAmountButtonPressed,
label: S.of(context).set_amount,
),
if (widget.showLabelButton)
ModernButton.svg(
size: 60,
iconSize: 32,
svgPath: "assets/new-ui/add-label.svg",
onPressed: widget.onLabelButtonPressed,
label: S.of(context).label),
size: 60,
iconSize: 32,
svgPath: "assets/new-ui/add-label.svg",
onPressed: widget.onLabelButtonPressed,
label: S.of(context).label,
),
if (widget.showAccountsButton)
ModernButton.svg(
size: 60,
iconSize: 32,
svgPath: "assets/new-ui/addr-book.svg",
onPressed: widget.onAccountsButtonPressed,
label: S.of(context).addresses),
size: 60,
iconSize: 32,
svgPath: "assets/new-ui/addr-book.svg",
onPressed: widget.onAccountsButtonPressed,
label: S.of(context).addresses,
),
],
),
),
@@ -107,20 +104,4 @@ class _ReceiveBottomButtonsState extends State<ReceiveBottomButtons> {
),
);
}
// android 13 (sdk 33) added a built-in "text was copied to clipboard" ui element
// older android and iphone still needs an indicator though
Future<bool> shouldShowCopied() async {
if (!Platform.isAndroid) return true;
try {
final deviceInfo = DeviceInfoPlugin();
final androidInfo = await deviceInfo.androidInfo;
final sdk = androidInfo.version.sdkInt;
return sdk < 33;
} catch (_) {
return true;
}
}
}
@@ -2,8 +2,10 @@ import 'dart:math';
import 'package:hash_wallet/entities/auto_generate_subaddress_status.dart';
import 'package:hash_wallet/generated/i18n.dart';
import 'package:hash_wallet/new-ui/widgets/coins_page/token_image_widget.dart';
import 'package:hash_wallet/src/widgets/hash_image_widget.dart';
import 'package:cw_core/crypto_currency.dart';
import "package:cw_core/currency_for_wallet_type.dart";
import 'package:cw_core/wallet_type.dart';
import 'package:flutter/material.dart';
@@ -15,10 +17,15 @@ class ReceiveInfoBox extends StatelessWidget {
required this.onDismissed,
this.bottomWidget});
/// [addressRotates] tells whether the address type currently selected on the
/// receive page actually rotates. Most wallet types only ever expose rotating
/// address types, so it defaults to true; Zcash also offers static types, for
/// which the rotation notice would be wrong.
static ReceiveInfoBox? forWalletType(WalletType type,
{required VoidCallback onDismissed,
required AutoGenerateSubaddressStatus autoGenerateSubaddressStatus,
List<CryptoCurrency>? supportedCurrencies}) {
List<CryptoCurrency>? supportedCurrencies,
bool addressRotates = true}) {
switch (type) {
case WalletType.nano:
return null;
@@ -34,11 +41,11 @@ class ReceiveInfoBox extends StatelessWidget {
onDismissed: onDismissed,
bottomWidget: InfoboxCurrencyRow(
currencies: supportedCurrencies ?? [],
chainIconPath:
"assets/new-ui/chain_badges/${walletTypeToString(type).toLowerCase()}.svg",
chainIconPath: walletTypeToCryptoCurrency(type).chainIconPath ?? "",
));
default:
if (autoGenerateSubaddressStatus == AutoGenerateSubaddressStatus.disabled) return null;
if (!addressRotates) return null;
return ReceiveInfoBox(
iconPath: "assets/new-ui/info.svg",
message: S.current.infobox_auto_address,
@@ -67,12 +74,14 @@ class ReceiveInfoBox extends StatelessWidget {
spacing: 10,
children: [
if (iconPath.isNotEmpty)
HashImageWidget(
imageUrl: iconPath,
width: 16,
height: 16,
colorFilter: ColorFilter.mode(
Theme.of(context).colorScheme.onSurfaceVariant, BlendMode.srcIn),
ExcludeSemantics(
child: HashImageWidget(
imageUrl: iconPath,
width: 16,
height: 16,
colorFilter: ColorFilter.mode(
Theme.of(context).colorScheme.onSurfaceVariant, BlendMode.srcIn),
),
),
Flexible(
child: Column(
@@ -92,15 +101,20 @@ class ReceiveInfoBox extends StatelessWidget {
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
if (bottomWidget != null) bottomWidget!,
GestureDetector(
onTap: onDismissed,
child: Text(
S.of(context).dismiss,
style: TextStyle(
color: Theme.of(context).colorScheme.primary,
fontSize: 12,
fontWeight: FontWeight.w300),
)),
MergeSemantics(
child: Semantics(
button: true,
child: GestureDetector(
onTap: onDismissed,
child: Text(
S.of(context).dismiss,
style: TextStyle(
color: Theme.of(context).colorScheme.primary,
fontSize: 12,
fontWeight: FontWeight.w300),
)),
),
),
],
)
]),
@@ -132,66 +146,69 @@ class InfoboxCurrencyRow extends StatelessWidget {
final double stackWidth = iconSize + (overlap * (currenciesLimited.length));
return Row(
spacing: 8,
children: [
HashImageWidget(
imageUrl: chainIconPath,
width: 20,
height: 20,
colorFilter:
ColorFilter.mode(Theme.of(context).colorScheme.onSurfaceVariant, BlendMode.srcIn),
),
Container(
height: 28,
width: 1,
color: Theme.of(context).colorScheme.surfaceContainerHigh,
),
SizedBox(
height: iconSize + iconBorder * 2,
width: stackWidth,
child: Stack(
children: [
Positioned(
top: iconBorder,
left: overlap * currenciesLimited.length,
child: Container(
width: 24,
height: 24,
decoration: BoxDecoration(
color: Colors.white.withAlpha(25),
borderRadius: BorderRadius.circular(9999999)),
child: Icon(
Icons.add,
size: 16,
color: Colors.white.withAlpha(128),
// Purely illustrative: the meaning ("receive any token on <chain>") is
// carried by the infobox message text next to it.
return ExcludeSemantics(
child: Row(
spacing: 8,
children: [
HashImageWidget(
imageUrl: chainIconPath,
width: 20,
height: 20,
colorFilter:
ColorFilter.mode(Theme.of(context).colorScheme.onSurfaceVariant, BlendMode.srcIn),
),
Container(
height: 28,
width: 1,
color: Theme.of(context).colorScheme.surfaceContainerHigh,
),
SizedBox(
height: iconSize + iconBorder * 2,
width: stackWidth,
child: Stack(
children: [
Positioned(
top: iconBorder,
left: overlap * currenciesLimited.length,
child: Container(
width: 24,
height: 24,
decoration: BoxDecoration(
color: Colors.white.withAlpha(25),
borderRadius: BorderRadius.circular(9999999)),
child: Icon(
Icons.add,
size: 16,
color: Colors.white.withAlpha(128),
),
),
),
),
...currenciesLimited
.asMap()
.entries
.map((entry) => Positioned(
left: 16.0 * entry.key,
child: Container(
decoration: BoxDecoration(
border: Border.all(
color: Theme.of(context).colorScheme.surfaceContainer,
width: iconBorder),
borderRadius: BorderRadius.circular(9999999)),
child: HashImageWidget(
imageUrl: entry.value.iconPath,
width: 24,
height: 24,
...currenciesLimited
.asMap()
.entries
.map((entry) => Positioned(
left: 16.0 * entry.key,
child: Container(
decoration: BoxDecoration(
border: Border.all(
color: Theme.of(context).colorScheme.surfaceContainer,
width: iconBorder),
borderRadius: BorderRadius.circular(9999999)),
child: TokenImageWidget(
imageUrl: entry.value.iconPath ?? '',
size: 24,
),
),
),
))
.toList()
.reversed,
],
),
)
],
))
.toList()
.reversed,
],
),
)
],
),
);
}
}
@@ -66,6 +66,7 @@ class _ReceiveLabelModalState extends State<ReceiveLabelModal> {
ModalTopBar(
title: S.of(context).label_address,
leadingIcon: Icon(Icons.close),
leadingSemanticLabel: S.of(context).close,
onLeadingPressed: Navigator.of(context).pop,
onTrailingPressed: () {}),
Padding(
@@ -91,27 +92,32 @@ class _ReceiveLabelModalState extends State<ReceiveLabelModal> {
return SizedBox(width: 8);
},
itemBuilder: (context, index) {
return Container(
height: 36,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(999)),
child: Material(
color: Colors.transparent,
borderRadius: BorderRadius.circular(999),
child: InkWell(
borderRadius: BorderRadius.circular(999),
onTap: () {
_controller.text = defaultLabels[index];
},
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 10.0),
child: Center(
child: Text(
defaultLabels[index],
style: TextStyle(
fontWeight: FontWeight.w400,
color: Theme.of(context).colorScheme.primary),
return MergeSemantics(
child: Semantics(
button: true,
child: Container(
height: 36,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(999)),
child: Material(
color: Colors.transparent,
borderRadius: BorderRadius.circular(999),
child: InkWell(
borderRadius: BorderRadius.circular(999),
onTap: () {
_controller.text = defaultLabels[index];
},
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 10.0),
child: Center(
child: Text(
defaultLabels[index],
style: TextStyle(
fontWeight: FontWeight.w400,
color: Theme.of(context).colorScheme.primary),
),
),
),
),
),
@@ -22,14 +22,16 @@ class ReceiveLabelWidget extends StatelessWidget {
spacing: 8,
mainAxisSize: MainAxisSize.min,
children: [
ClipRect(
child: HashImageWidget(
imageUrl: "assets/new-ui/label.svg",
width: 24,
height: 24,
colorFilter: ColorFilter.mode(
Theme.of(context).colorScheme.onSurfaceVariant, BlendMode.srcIn),
)),
ExcludeSemantics(
child: ClipRect(
child: HashImageWidget(
imageUrl: "assets/new-ui/label.svg",
width: 24,
height: 24,
colorFilter: ColorFilter.mode(
Theme.of(context).colorScheme.onSurfaceVariant, BlendMode.srcIn),
)),
),
Text(name,
style: TextStyle(
fontSize: 12, color: Theme.of(context).colorScheme.onSurfaceVariant)),

Some files were not shown because too many files have changed in this diff Show More