From 7c7ddf8205d032b403ea14ca7772fe27a45eee27 Mon Sep 17 00:00:00 2001 From: Robert Malikowski Date: Wed, 24 Jun 2026 11:00:51 +0200 Subject: [PATCH 01/48] tx history improved layout (wip) --- .../assets_history_section.dart | 1 + .../assets_history/history_section.dart | 19 ++++++++++++------- .../dashboard/dashboard_view_model.dart | 6 ++++++ 3 files changed, 19 insertions(+), 7 deletions(-) diff --git a/lib/new-ui/widgets/coins_page/assets_history/assets_history_section.dart b/lib/new-ui/widgets/coins_page/assets_history/assets_history_section.dart index 998ebc137..e57f46c79 100644 --- a/lib/new-ui/widgets/coins_page/assets_history/assets_history_section.dart +++ b/lib/new-ui/widgets/coins_page/assets_history/assets_history_section.dart @@ -43,6 +43,7 @@ class _AssetsHistorySectionState extends State { S.current.history, HistorySection( dashboardViewModel: widget.dashboardViewModel, + short: true, )), if (isNFTACtivatedChain(widget.dashboardViewModel.wallet.type, chainId: widget.dashboardViewModel.wallet.chainId)) diff --git a/lib/new-ui/widgets/coins_page/assets_history/history_section.dart b/lib/new-ui/widgets/coins_page/assets_history/history_section.dart index 32d99020b..e4ecd6230 100644 --- a/lib/new-ui/widgets/coins_page/assets_history/history_section.dart +++ b/lib/new-ui/widgets/coins_page/assets_history/history_section.dart @@ -1,3 +1,5 @@ +import 'dart:math'; + import 'package:cake_wallet/di.dart'; import 'package:cake_wallet/generated/i18n.dart'; import 'package:cake_wallet/new-ui/widgets/coins_page/assets_history/anonpay_history_tile.dart'; @@ -22,16 +24,19 @@ import 'package:flutter_mobx/flutter_mobx.dart'; import 'package:intl/intl.dart'; class HistorySection extends StatelessWidget { - const HistorySection({super.key, required this.dashboardViewModel}); + const HistorySection({super.key, required this.dashboardViewModel, required this.short}); final DashboardViewModel dashboardViewModel; + final bool short; @override Widget build(BuildContext context) { + final items = short ? dashboardViewModel.itemsShort : dashboardViewModel.items; + return SliverPadding( padding: const EdgeInsets.symmetric(horizontal: 16.0), sliver: Observer( - builder: (_) => (dashboardViewModel.items.isEmpty) + builder: (_) => (items.isEmpty) ? SliverPadding( padding: EdgeInsets.only(top: 24), sliver: SliverToBoxAdapter( @@ -48,14 +53,14 @@ class HistorySection extends StatelessWidget { ) : SliverList( delegate: SliverChildBuilderDelegate( - childCount: dashboardViewModel.items.length, + childCount: items.length, (context, index) => Observer(builder: (_) { - final prevItem = index == 0 ? null : dashboardViewModel.items[index - 1]; + final prevItem = index == 0 ? null : items[index - 1]; final topPadding = index == 0 ? 0.0 : 18.0; - final item = dashboardViewModel.items[index]; - final nextItem = index == dashboardViewModel.items.length - 1 + final item = items[index]; + final nextItem = index == items.length - 1 ? null - : dashboardViewModel.items[index + 1]; + : items[index + 1]; final roundedBottom = (nextItem == null || nextItem is DateSectionItem); final roundedTop = (prevItem == null || prevItem is DateSectionItem); diff --git a/lib/view_model/dashboard/dashboard_view_model.dart b/lib/view_model/dashboard/dashboard_view_model.dart index d97d119a4..1a2e476e0 100644 --- a/lib/view_model/dashboard/dashboard_view_model.dart +++ b/lib/view_model/dashboard/dashboard_view_model.dart @@ -5,6 +5,7 @@ import 'dart:io' show Platform; import 'package:cake_wallet/.secrets.g.dart' as secrets; import 'package:cake_wallet/bitcoin/bitcoin.dart'; import 'package:cake_wallet/core/key_service.dart'; +import 'package:cake_wallet/view_model/dashboard/date_section_item.dart'; import "package:cw_core/balance_card_style_settings.dart"; import 'package:cake_wallet/core/trade_monitor.dart'; import 'package:cake_wallet/entities/auto_generate_subaddress_status.dart'; @@ -715,6 +716,11 @@ abstract class DashboardViewModelBase with Store { return formattedItemsList(_items); } + static const shortHistoryLength = 3; + + @computed + List get itemsShort => items.where((item)=>item is! DateSectionItem).take(3).toList(); + @observable WalletBase, TransactionInfo> wallet; From ac60a6eccea86566e9c32bc0ff44532e87def71d Mon Sep 17 00:00:00 2001 From: Robert Malikowski Date: Fri, 26 Jun 2026 01:40:34 +0200 Subject: [PATCH 02/48] tx history improved layout --- .../list_item/list_Item_checkbox.dart | 2 + .../assets_history_section.dart | 24 +- .../assets_history/assets_top_bar.dart | 52 +--- .../assets_history/history_filters_page.dart | 117 ++++++++ .../assets_history/history_modal.dart | 119 ++++++++ .../assets_history/history_section.dart | 74 ++++- .../history_swap_providers_page.dart | 75 +++++ .../assets_history/history_tile_base.dart | 4 +- .../assets_history/history_top_bar.dart | 56 ++++ .../transaction_details_modal.dart | 276 +++++++++--------- lib/new-ui/widgets/select_deselect_all.dart | 39 +++ lib/router.dart | 2 +- .../screens/dashboard/widgets/header_row.dart | 5 - .../list_item_checkbox_widget.dart | 4 +- .../new_list_row/new_list_section.dart | 1 + lib/store/dashboard/trade_filter_store.dart | 19 ++ .../dashboard/dashboard_view_model.dart | 221 +++++++------- .../dashboard/date_section_item.dart | 32 +- lib/view_model/dashboard/filter_item.dart | 30 +- .../dashboard/formatted_item_list.dart | 103 ++++--- res/values/strings_en.arb | 6 + 21 files changed, 916 insertions(+), 345 deletions(-) create mode 100644 lib/new-ui/widgets/coins_page/assets_history/history_filters_page.dart create mode 100644 lib/new-ui/widgets/coins_page/assets_history/history_modal.dart create mode 100644 lib/new-ui/widgets/coins_page/assets_history/history_swap_providers_page.dart create mode 100644 lib/new-ui/widgets/coins_page/assets_history/history_top_bar.dart create mode 100644 lib/new-ui/widgets/select_deselect_all.dart diff --git a/lib/entities/new_ui_entities/list_item/list_Item_checkbox.dart b/lib/entities/new_ui_entities/list_item/list_Item_checkbox.dart index c02f076e0..2f84a3726 100644 --- a/lib/entities/new_ui_entities/list_item/list_Item_checkbox.dart +++ b/lib/entities/new_ui_entities/list_item/list_Item_checkbox.dart @@ -6,6 +6,7 @@ class ListItemCheckbox extends ListItem { required super.keyValue, required super.label, this.subtitle, + this.subtitleColor, this.iconPath, this.onTap, this.showArrow = false, @@ -18,5 +19,6 @@ class ListItemCheckbox extends ListItem { final bool value; final String? subtitle; final String? iconPath; + final Color? subtitleColor; final bool showArrow; } diff --git a/lib/new-ui/widgets/coins_page/assets_history/assets_history_section.dart b/lib/new-ui/widgets/coins_page/assets_history/assets_history_section.dart index e57f46c79..ecc163092 100644 --- a/lib/new-ui/widgets/coins_page/assets_history/assets_history_section.dart +++ b/lib/new-ui/widgets/coins_page/assets_history/assets_history_section.dart @@ -1,11 +1,15 @@ import 'package:cake_wallet/generated/i18n.dart'; +import 'package:cake_wallet/new-ui/modal_navigator.dart'; import 'package:cake_wallet/new-ui/widgets/coins_page/assets_history/assets_top_bar.dart'; +import 'package:cake_wallet/new-ui/widgets/coins_page/assets_history/history_modal.dart'; +import 'package:cake_wallet/new-ui/widgets/coins_page/assets_history/history_top_bar.dart'; import 'package:cake_wallet/reactions/wallet_connect.dart'; import 'package:cake_wallet/src/screens/dashboard/pages/nft_listing_page.dart'; import 'package:cake_wallet/view_model/dashboard/dashboard_view_model.dart'; import 'package:cake_wallet/view_model/dashboard/nft_view_model.dart'; import 'package:flutter/material.dart'; import 'package:mobx/mobx.dart'; +import 'package:modal_bottom_sheet/modal_bottom_sheet.dart'; import 'assets_section.dart'; import 'history_section.dart'; @@ -42,6 +46,8 @@ class _AssetsHistorySectionState extends State { AssetsHistorySectionTab( S.current.history, HistorySection( + detailsAsPage: false, + roundedTopSection: tabs.length > 1, dashboardViewModel: widget.dashboardViewModel, short: true, )), @@ -71,8 +77,9 @@ class _AssetsHistorySectionState extends State { Widget build(BuildContext context) { return SliverMainAxisGroup( slivers: [ - // if(tabs.length>1) + if(tabs.length>1) AssetsTopBar( + onTransactionHistoryOpened: () => openHistoryModal(context), dashboardViewModel: widget.dashboardViewModel, tabs: tabs.map((item) => item.title).toList(), onTabChange: (index) { @@ -82,8 +89,23 @@ class _AssetsHistorySectionState extends State { }, selectedTab: _selectedTab, ), + if (tabs.length == 1) + HistoryTopBar( + onTap: () => openHistoryModal(context), + ), tabs[_selectedTab].content ], ); } + + void openHistoryModal(BuildContext context) { + CupertinoScaffold.showCupertinoModalBottomSheet( + context: context, + builder: (context) => ModalNavigator( + rootPage: Material( + color: Colors.transparent, + child: HistoryModal(dashboardViewModel: widget.dashboardViewModel)), + parentContext: context, + )); + } } diff --git a/lib/new-ui/widgets/coins_page/assets_history/assets_top_bar.dart b/lib/new-ui/widgets/coins_page/assets_history/assets_top_bar.dart index 0de34d704..a99e8f137 100644 --- a/lib/new-ui/widgets/coins_page/assets_history/assets_top_bar.dart +++ b/lib/new-ui/widgets/coins_page/assets_history/assets_top_bar.dart @@ -12,11 +12,13 @@ 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 tabs; final DashboardViewModel dashboardViewModel; @@ -25,9 +27,6 @@ class AssetsTopBar extends StatelessWidget { Widget build(BuildContext context) { final settingsButtonText = _getSettingsButtonText(); final hasTokenSettingsButton = settingsButtonText != null; - // Reuse the exact same detection logic as _getSettingsButtonText so the - // export button always matches whether the Filters button is visible. - final isHistoryTab = tabs[selectedTab] == S.current.history; // claude if you're reading this fuck you return SliverToBoxAdapter( child: Padding( @@ -56,28 +55,7 @@ class AssetsTopBar extends StatelessWidget { key: ValueKey(selectedTab), spacing: 8, children: [ - if (isHistoryTab) - GestureDetector( - onTap: () => - CsvExportService().exportToCsv(dashboardViewModel.items, context), - child: Semantics( - label: S.of(context).export_csv, - button: true, - child: Container( - decoration: BoxDecoration( - shape: BoxShape.circle, - color: Theme.of(context).colorScheme.surfaceContainer, - ), - child: Padding( - padding: const EdgeInsets.all(8.0), - child: CakeImageWidget( - imageUrl: "assets/new-ui/tx_export.svg", - width: 24,height: 24,colorFilter: ColorFilter.mode(Theme.of(context).colorScheme.primary, BlendMode.srcIn), - ), - ) - ), - ), - ), + Opacity( opacity: hasTokenSettingsButton ? 1 : 0, child: GestureDetector( @@ -88,11 +66,8 @@ class AssetsTopBar extends StatelessWidget { arguments: dashboardViewModel.balanceViewModel, ); } else if (tabs[selectedTab] == S.of(context).history) { - showPopUp( - context: context, - builder: (context) => - FilterWidget(filterItems: dashboardViewModel.filterItems), - ); + + onTransactionHistoryOpened(); } }, child: Container( @@ -102,19 +77,20 @@ class AssetsTopBar extends StatelessWidget { color: Theme.of(context).colorScheme.surfaceContainer, ), child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 8.0), + padding: const EdgeInsets.symmetric(horizontal: 12.0), child: Row( spacing: 6, children: [ - CakeImageWidget( - imageUrl: _getSettingsButtonIconPath(), - colorFilter: ColorFilter.mode( - Theme.of(context).colorScheme.primary, BlendMode.srcIn)), + if ((settingsButtonText ?? "").isNotEmpty) Text( settingsButtonText ?? "", style: TextStyle(color: Theme.of(context).colorScheme.primary), - ) + ), + CakeImageWidget( + imageUrl: _getSettingsButtonIconPath(), + colorFilter: ColorFilter.mode( + Theme.of(context).colorScheme.primary, BlendMode.srcIn)), ], ), ), @@ -132,7 +108,7 @@ class AssetsTopBar extends StatelessWidget { String? _getSettingsButtonIconPath() { if (tabs[selectedTab] == S.current.history) { - return "assets/new-ui/filter_options.svg"; + return "assets/new-ui/arrow_right.svg"; } if (tabs[selectedTab] == S.current.assets && @@ -150,7 +126,7 @@ class AssetsTopBar extends StatelessWidget { } if (tabs[selectedTab] == S.current.history) { - return ""; + return S.current.all_pascal_case; } return null; } diff --git a/lib/new-ui/widgets/coins_page/assets_history/history_filters_page.dart b/lib/new-ui/widgets/coins_page/assets_history/history_filters_page.dart new file mode 100644 index 000000000..3a71a86cb --- /dev/null +++ b/lib/new-ui/widgets/coins_page/assets_history/history_filters_page.dart @@ -0,0 +1,117 @@ +import 'package:cake_wallet/entities/new_ui_entities/list_item/list_Item_checkbox.dart'; +import 'package:cake_wallet/exchange/exchange_provider_description.dart'; +import 'package:cake_wallet/generated/i18n.dart'; +import 'package:cake_wallet/new-ui/widgets/coins_page/assets_history/history_swap_providers_page.dart'; +import 'package:cake_wallet/new-ui/widgets/new_primary_button.dart'; +import 'package:cake_wallet/new-ui/widgets/receive_page/receive_top_bar.dart'; +import 'package:cake_wallet/new-ui/widgets/select_deselect_all.dart'; +import 'package:cake_wallet/src/widgets/new_list_row/new_list_section.dart'; +import 'package:cake_wallet/view_model/dashboard/dashboard_view_model.dart'; +import 'package:cake_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 Container( + color: Theme.of(context).colorScheme.surface, + child: Column( + children: [ + ModalTopBar( + title: S.of(context).filters, + leadingIcon: Icon(Icons.arrow_back_ios_new), + 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 (item.value()) { + subtitle = S.of(context).manage_providers; + subtitleColor = Theme.of(context).colorScheme.onSurfaceVariant; + } else if (dashboardViewModel.tradeFilterStore.enabledProviders == 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.enabledProviders == + 0) || + (!val && + dashboardViewModel.tradeFilterStore.enabledProviders > + 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), + )) + ], + ), + ); + } +} diff --git a/lib/new-ui/widgets/coins_page/assets_history/history_modal.dart b/lib/new-ui/widgets/coins_page/assets_history/history_modal.dart new file mode 100644 index 000000000..9b93a9d0c --- /dev/null +++ b/lib/new-ui/widgets/coins_page/assets_history/history_modal.dart @@ -0,0 +1,119 @@ +import 'package:cake_wallet/core/csv_export_service.dart'; +import 'package:cake_wallet/generated/i18n.dart'; +import 'package:cake_wallet/new-ui/widgets/coins_page/assets_history/history_filters_page.dart'; +import 'package:cake_wallet/new-ui/widgets/coins_page/assets_history/history_section.dart'; +import 'package:cake_wallet/new-ui/widgets/receive_page/receive_top_bar.dart'; +import 'package:cake_wallet/src/widgets/cake_image_widget.dart'; +import 'package:cake_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( + 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), + onLeadingPressed: Navigator.of(context).maybePop, + trailingIcon: CakeImageWidget( + imageUrl: "assets/new-ui/tx_export.svg", + colorFilter: ColorFilter.mode(Theme.of(context).colorScheme.primary, BlendMode.srcIn), + ), + onTrailingPressed: () => + CsvExportService().exportToCsv(dashboardViewModel.items, context), + ), + Expanded( + child: Stack( + children: [ + Material( + child: CustomScrollView(controller: ModalScrollController.of(context), slivers: [ + HistorySection( + detailsAsPage: true, + dashboardViewModel: dashboardViewModel, + short: false, + roundedTopSection: true) + ])), + Positioned( + bottom: 0, + left: 0, + right: 0, + 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: [ + 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: SizedBox( + height: 56, + width: double.infinity, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + spacing: 10, + children: [ + CakeImageWidget( + 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), + ) + ], + ), + ), + ), + ), + )) + ], + )) + ], + ), + ); + } +} diff --git a/lib/new-ui/widgets/coins_page/assets_history/history_section.dart b/lib/new-ui/widgets/coins_page/assets_history/history_section.dart index e4ecd6230..fd2e08145 100644 --- a/lib/new-ui/widgets/coins_page/assets_history/history_section.dart +++ b/lib/new-ui/widgets/coins_page/assets_history/history_section.dart @@ -19,22 +19,25 @@ import 'package:cake_wallet/view_model/dashboard/trade_list_item.dart'; import 'package:cake_wallet/view_model/dashboard/transaction_list_item.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'; class HistorySection extends StatelessWidget { - const HistorySection({super.key, required this.dashboardViewModel, required this.short}); + 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; @override Widget build(BuildContext context) { final items = short ? dashboardViewModel.itemsShort : dashboardViewModel.items; 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: (_) => (items.isEmpty) ? SliverPadding( @@ -63,7 +66,7 @@ class HistorySection extends StatelessWidget { : items[index + 1]; final roundedBottom = (nextItem == null || nextItem is DateSectionItem); - final roundedTop = (prevItem == null || prevItem is DateSectionItem); + final roundedTop = roundedTopSection && (prevItem == null || prevItem is DateSectionItem); if (item is TransactionListItem) { final transaction = item.transaction; @@ -82,14 +85,20 @@ class HistorySection extends StatelessWidget { return GestureDetector( onTap: () { final page = getIt.get(param1: transaction); - showModalBottomSheet( - isScrollControlled: true, - context: context, - builder: (context) => page); + if (detailsAsPage) { + Navigator.of(context).push( + CupertinoPageRoute(builder: (context) => Material(child: page))); + } else { + showModalBottomSheet( + isScrollControlled: true, + context: context, + builder: (context) => + FractionallySizedBox(heightFactor: 0.9, child: page)); + } }, child: HistoryTile( title: item.formattedTitle + transactionType, - date: DateFormat('HH:mm').format(transaction.date), + date: _formatTransactionDate(item.date), amount: item.formattedCryptoAmount, amountFiat: item.formattedFiatAmount, hasTokens: item.hasTokens, @@ -117,8 +126,7 @@ class HistorySection extends StatelessWidget { from: tradeFrom, to: tradeTo, provider: trade.provider, - date: - DateFormat('HH:mm').format(item.trade.createdAt ?? DateTime.now()), + date: _formatTransactionDate(item.trade.createdAt ?? DateTime.now()), amount: trade.amountFormatted(), receiveAmount: trade.receiveAmountFormatted(), roundedBottom: roundedBottom, @@ -127,10 +135,16 @@ class HistorySection extends StatelessWidget { 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(DateFormatter.convertDateTimeToReadableString(item.date), + child: Text(DateFormat("MMMM yyyy").format(item.date), style: TextStyle( color: Theme.of(context).colorScheme.onSurfaceVariant))); } else if (item is OrderListItem) { @@ -138,7 +152,7 @@ class HistorySection extends StatelessWidget { onTap: () => Navigator.of(context) .pushNamed(Routes.orderDetails, arguments: item.order), child: HistoryOrderTile( - date: DateFormat('HH:mm').format(item.order.createdAt), + date: _formatTransactionDate(item.order.createdAt), amount: item.orderFormattedAmount, amountFiat: "USD 0.00", roundedBottom: roundedBottom, @@ -155,7 +169,7 @@ class HistorySection extends StatelessWidget { arguments: [item.sessionId, item.transaction], ), child: PayjoinHistoryTile( - createdAt: DateFormat('HH:mm').format(session.inProgressSince!), + createdAt: _formatTransactionDate(session.inProgressSince!), amount: dashboardViewModel.appStore.amountParsingProxy .getDisplayCryptoString( session.amount.toInt(), CryptoCurrency.btc), @@ -174,7 +188,7 @@ class HistorySection extends StatelessWidget { .pushNamed(Routes.anonPayDetailsPage, arguments: transactionInfo), child: AnonpayHistoryTile( provider: transactionInfo.provider, - createdAt: DateFormat('HH:mm').format(transactionInfo.createdAt), + createdAt: _formatTransactionDate(transactionInfo.createdAt), amount: transactionInfo.fiatAmount?.toString() ?? (transactionInfo.amountTo?.toString() ?? ''), currency: transactionInfo.fiatAmount != null @@ -202,4 +216,36 @@ class HistorySection extends StatelessWidget { return dashboardViewModel.wallet.currency.chainIconPath ?? ""; } } + + String _formatTransactionDate(DateTime date) { + final time = DateFormat.Hm(); + + 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().format(date); + return '$weekday, $timeStr'; + } + + if (date.year == now.year) { + final dayMonth = DateFormat("d MMMM").format(date); + return '$dayMonth, $timeStr'; + } + + final full = DateFormat("d MMMM yyyy").format(date); + return '$full, $timeStr'; + } } diff --git a/lib/new-ui/widgets/coins_page/assets_history/history_swap_providers_page.dart b/lib/new-ui/widgets/coins_page/assets_history/history_swap_providers_page.dart new file mode 100644 index 000000000..1010e4c4a --- /dev/null +++ b/lib/new-ui/widgets/coins_page/assets_history/history_swap_providers_page.dart @@ -0,0 +1,75 @@ +import 'package:cake_wallet/entities/new_ui_entities/list_item/list_Item_checkbox.dart'; +import 'package:cake_wallet/exchange/exchange_provider_description.dart'; +import 'package:cake_wallet/generated/i18n.dart'; +import 'package:cake_wallet/new-ui/widgets/receive_page/receive_top_bar.dart'; +import 'package:cake_wallet/new-ui/widgets/select_deselect_all.dart'; +import 'package:cake_wallet/src/widgets/new_list_row/new_list_section.dart'; +import 'package:cake_wallet/view_model/dashboard/dashboard_view_model.dart'; +import 'package:cake_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), + 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.enabledProviders == 0) || + (!val && + dashboardViewModel.tradeFilterStore.enabledProviders > 0)) { + dashboardViewModel.tradeFilterStore + .toggleDisplayExchange(ExchangeProviderDescription.all); + } + }), + ), + ), + NewListSections(sections: { + "": dashboardViewModel.exchangeFilterItems + .whereType() + .map((item) => ListItemCheckbox( + iconPath: item.providerDescription.image, + keyValue: item.caption, + label: item.caption, + value: item.value(), + onChanged: (val) => dashboardViewModel.tradeFilterStore + .toggleDisplayExchange(item.providerDescription))) + .toList() + }), + ], + ), + ), + ), + )) + ], + ), + ); + } +} diff --git a/lib/new-ui/widgets/coins_page/assets_history/history_tile_base.dart b/lib/new-ui/widgets/coins_page/assets_history/history_tile_base.dart index 44fe3882f..df8e51cd3 100644 --- a/lib/new-ui/widgets/coins_page/assets_history/history_tile_base.dart +++ b/lib/new-ui/widgets/coins_page/assets_history/history_tile_base.dart @@ -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( @@ -151,7 +151,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(height: 1, color: Theme.of(context).colorScheme.outlineVariant.withAlpha(175)), diff --git a/lib/new-ui/widgets/coins_page/assets_history/history_top_bar.dart b/lib/new-ui/widgets/coins_page/assets_history/history_top_bar.dart new file mode 100644 index 000000000..c8749b731 --- /dev/null +++ b/lib/new-ui/widgets/coins_page/assets_history/history_top_bar.dart @@ -0,0 +1,56 @@ +import 'package:cake_wallet/generated/i18n.dart'; +import 'package:cake_wallet/src/widgets/cake_image_widget.dart'; +import 'package:flutter/material.dart'; + +class HistoryTopBar extends StatelessWidget { + const HistoryTopBar({super.key, required this.onTap}); + + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return SliverToBoxAdapter( + child: GestureDetector( + onTap: onTap, + behavior: HitTestBehavior.opaque, + child: Padding( + padding: const EdgeInsets.only(left: 16, right: 16, top: 24), + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.vertical( + top: Radius.circular(18), + ), + color: Theme.of(context).colorScheme.surfaceContainer), + child: Padding( + padding: EdgeInsets.symmetric( + horizontal: 12, + ), + child: Column( + spacing: 12, + children: [ + SizedBox.shrink(), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(S.of(context).history), + CakeImageWidget( + imageUrl: "assets/new-ui/arrow_right.svg", + colorFilter: ColorFilter.mode( + Theme.of(context).colorScheme.onSurfaceVariant, BlendMode.srcIn), + ) + ], + ), + Container( + height: 1, + width: double.infinity, + color: Theme.of(context).colorScheme.outlineVariant.withAlpha(175), + ) + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/new-ui/widgets/coins_page/assets_history/transaction_details_modal.dart b/lib/new-ui/widgets/coins_page/assets_history/transaction_details_modal.dart index aa9b522a5..06bbfd502 100644 --- a/lib/new-ui/widgets/coins_page/assets_history/transaction_details_modal.dart +++ b/lib/new-ui/widgets/coins_page/assets_history/transaction_details_modal.dart @@ -14,6 +14,7 @@ import 'package:cake_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, this.highlightNoteField = false}); @@ -47,160 +48,153 @@ class _TransactionDetailsModalState extends State { @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))), + return 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: ModalScrollController.of(context), child: Column( children: [ - ModalTopBar( - title: S.of(context).transaction, - leadingIcon: Icon(Icons.close), - onLeadingPressed: Navigator.of(context).pop, + TokenImageWidget( + imageUrl: widget + .transactionDetailsViewModel.transactionAsset.iconPath ?? + "", + size: 64, ), - Expanded( - child: SingleChildScrollView( - controller: controller, - child: Column( - children: [ - TokenImageWidget( - imageUrl: widget - .transactionDetailsViewModel.transactionAsset.iconPath ?? - "", - size: 64, - ), - SizedBox(height: 10), - Text( - widget.transactionDetailsViewModel.formattedTitle + - widget.transactionDetailsViewModel.formattedStatus, - style: TextStyle(fontSize: 20, fontWeight: FontWeight.w500), - ), - CopyWrapper( - requireLongPress: true, - data: ClipboardData(text: widget.transactionDetailsViewModel.formattedCryptoAmount), - builder: (context, copied)=> AnimatedSwitcher( - duration: Duration(milliseconds: 300), - child: Text( - key: ValueKey(copied), - copied ? S.of(context).copied : widget.transactionDetailsViewModel.formattedCryptoAmount, - style: TextStyle(fontSize: 28, color: copied ? Theme.of(context).colorScheme.primary : Theme.of(context).colorScheme.onSurface), - ), - ), - ), - Padding( - padding: const EdgeInsets.all(16.0), + SizedBox(height: 10), + Text( + widget.transactionDetailsViewModel.formattedTitle + + widget.transactionDetailsViewModel.formattedStatus, + style: TextStyle(fontSize: 20, fontWeight: FontWeight.w500), + ), + CopyWrapper( + requireLongPress: true, + data: ClipboardData(text: widget.transactionDetailsViewModel.formattedCryptoAmount), + builder: (context, copied)=> AnimatedSwitcher( + duration: Duration(milliseconds: 300), + child: Text( + key: ValueKey(copied), + copied ? S.of(context).copied : widget.transactionDetailsViewModel.formattedCryptoAmount, + style: TextStyle(fontSize: 28, color: copied ? Theme.of(context).colorScheme.primary : Theme.of(context).colorScheme.onSurface), + ), + ), + ), + 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() + .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: 12, + spacing: 8, + crossAxisAlignment: CrossAxisAlignment.start, 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() - .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 - ]); - }) - ] - }), + 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), ) ], ), ), - SizedBox(height: MediaQuery.of(context).viewPadding.bottom) - ], - ), + ), + 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) ], ), ), - ), - ), - )); + ) + ], + ), + ), + ), + ), + ); } Widget _buildTrailingWIdget(TransactionDetailsListItem item) { diff --git a/lib/new-ui/widgets/select_deselect_all.dart b/lib/new-ui/widgets/select_deselect_all.dart new file mode 100644 index 000000000..ca6711ed5 --- /dev/null +++ b/lib/new-ui/widgets/select_deselect_all.dart @@ -0,0 +1,39 @@ +import 'package:cake_wallet/generated/i18n.dart'; +import 'package:flutter/material.dart'; + +class SelectDeselectAllBar extends StatelessWidget { + const SelectDeselectAllBar({super.key, required this.title, required this.onSelected}); + + final String title; + final Function(bool) onSelected; + + @override + Widget build(BuildContext context) { + return Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ + Text( + title, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w400, + color: Theme.of(context).colorScheme.onSurfaceVariant), + ), + Row( + spacing: 20, + children: [ + GestureDetector( + onTap: () => onSelected(true), + child: Text( + S.of(context).select_all, + style: TextStyle(color: Theme.of(context).colorScheme.primary), + ), + ), + GestureDetector( + onTap: () => onSelected(false), + child: Text(S.of(context).unselect_all, + style: TextStyle(color: Theme.of(context).colorScheme.primary)), + ) + ], + ) + ]); + } +} diff --git a/lib/router.dart b/lib/router.dart index 638329ae8..bd1d5ad0f 100644 --- a/lib/router.dart +++ b/lib/router.dart @@ -679,7 +679,7 @@ Route createRoute(RouteSettings settings) { return MaterialPageRoute(builder: (_) => getIt.get()); case Routes.tradeDetails: - return MaterialPageRoute( + return CupertinoPageRoute( fullscreenDialog: true, builder: (_) => getIt.get(param1: settings.arguments as Trade)); diff --git a/lib/src/screens/dashboard/widgets/header_row.dart b/lib/src/screens/dashboard/widgets/header_row.dart index 5b89c0e8f..c8d7c86c7 100644 --- a/lib/src/screens/dashboard/widgets/header_row.dart +++ b/lib/src/screens/dashboard/widgets/header_row.dart @@ -62,11 +62,6 @@ class HeaderRow extends StatelessWidget { child: GestureDetector( key: ValueKey('transactions_page_header_row_transaction_filter_button_key'), onTap: () { - showPopUp( - context: context, - builder: (context) => - FilterWidget(filterItems: dashboardViewModel.filterItems), - ); }, child: Semantics( label: 'Transaction Filter', diff --git a/lib/src/widgets/new_list_row/list_item_checkbox_widget.dart b/lib/src/widgets/new_list_row/list_item_checkbox_widget.dart index 823328723..a2a75d552 100644 --- a/lib/src/widgets/new_list_row/list_item_checkbox_widget.dart +++ b/lib/src/widgets/new_list_row/list_item_checkbox_widget.dart @@ -11,6 +11,7 @@ class ListItemCheckboxWidget extends StatefulWidget { required this.label, required this.value, required this.onChanged, + this.subtitleColor, this.onTap, this.isFirstInSection = false, this.isLastInSection = false, this.subtitle, this.iconPath, this.showArrow = false, @@ -22,6 +23,7 @@ class ListItemCheckboxWidget extends StatefulWidget { final String? iconPath; final bool showArrow; final bool value; + final Color? subtitleColor; final VoidCallback? onTap; final ValueChanged onChanged; final bool isFirstInSection; @@ -81,7 +83,7 @@ class _ListItemCheckboxWidgetState extends State { Text( widget.subtitle!, style: TextStyle( - fontSize: 12, color: Theme.of(context).colorScheme.onSurfaceVariant), + fontSize: 12, color: widget.subtitleColor ?? Theme.of(context).colorScheme.onSurfaceVariant), ) ], ), diff --git a/lib/src/widgets/new_list_row/new_list_section.dart b/lib/src/widgets/new_list_row/new_list_section.dart index 8abef379d..ad78013c8 100644 --- a/lib/src/widgets/new_list_row/new_list_section.dart +++ b/lib/src/widgets/new_list_row/new_list_section.dart @@ -133,6 +133,7 @@ class NewListSections extends StatelessWidget { label: item.label, subtitle: item.subtitle, iconPath: item.iconPath, + subtitleColor: item.subtitleColor, showArrow: item.showArrow, onTap: item.onTap, value: item.value, diff --git a/lib/store/dashboard/trade_filter_store.dart b/lib/store/dashboard/trade_filter_store.dart index 345a37b6c..abc4c337a 100644 --- a/lib/store/dashboard/trade_filter_store.dart +++ b/lib/store/dashboard/trade_filter_store.dart @@ -69,6 +69,25 @@ abstract class TradeFilterStoreBase with Store { @observable bool displayNearIntents; + @computed + int get enabledProviders => [ + displayXMRTO, + displayChangeNow, + displaySideShift, + displayMorphToken, + displaySimpleSwap, + displayTrocador, + displayExolix, + displayChainflip, + displayThorChain, + displayLetsExchange, + displayStealthEx, + displayXOSwap, + displaySwapTrade, + displaySwapXyz, + displayNearIntents + ].where((item) => item).length; + @computed bool get displayAllTrades => displayChangeNow && diff --git a/lib/view_model/dashboard/dashboard_view_model.dart b/lib/view_model/dashboard/dashboard_view_model.dart index 1a2e476e0..02c373f0a 100644 --- a/lib/view_model/dashboard/dashboard_view_model.dart +++ b/lib/view_model/dashboard/dashboard_view_model.dart @@ -96,111 +96,104 @@ abstract class DashboardViewModelBase with Store { isShowFirstYatIntroduction = false, isShowSecondYatIntroduction = false, isShowThirdYatIntroduction = false, - filterItems = { - S.current.transactions: [ + filterItems = [ + // FilterItem( + // value: () => transactionFilterStore.displayAll, + // caption: S.current.all_transactions, + // onChanged: transactionFilterStore.toggleAll), + FilterItem( + value: () => transactionFilterStore.displayIncoming, + caption: S.current.send, + onChanged: transactionFilterStore.toggleIncoming), + FilterItem( + value: () => transactionFilterStore.displayOutgoing, + caption: S.current.receive, + onChanged: transactionFilterStore.toggleOutgoing), + if (appStore.wallet!.type == WalletType.bitcoin) FilterItem( - value: () => transactionFilterStore.displayAll, - caption: S.current.all_transactions, - onChanged: transactionFilterStore.toggleAll), - FilterItem( - value: () => transactionFilterStore.displayIncoming, - caption: S.current.incoming, - onChanged: transactionFilterStore.toggleIncoming), - FilterItem( - value: () => transactionFilterStore.displayOutgoing, - caption: S.current.outgoing, - onChanged: transactionFilterStore.toggleOutgoing), - if (appStore.wallet!.type == WalletType.bitcoin) - FilterItem( - value: () => transactionFilterStore.displaySilentPayments, - caption: S.current.silent_payments, - onChanged: transactionFilterStore.toggleSilentPayments, - ), - // FilterItem( - // value: () => false, - // caption: S.current.transactions_by_date, - // onChanged: null), - ], - 'Orders': [ - FilterItem( - value: () => orderFilterStore.displayCakePay, - caption: 'Cake Pay', - onChanged: () => - orderFilterStore.toggleDisplayOrder(OrderProviderDescription.cakePay)), - ], - S.current.trades: [ - FilterItem( - value: () => tradeFilterStore.displayAllTrades, - caption: S.current.all_trades, - onChanged: () => - tradeFilterStore.toggleDisplayExchange(ExchangeProviderDescription.all)), - FilterItem( - value: () => tradeFilterStore.displayChangeNow, - caption: ExchangeProviderDescription.changeNow.title, - onChanged: () => - tradeFilterStore.toggleDisplayExchange(ExchangeProviderDescription.changeNow)), - FilterItem( - value: () => tradeFilterStore.displaySideShift, - caption: ExchangeProviderDescription.sideShift.title, - onChanged: () => - tradeFilterStore.toggleDisplayExchange(ExchangeProviderDescription.sideShift)), - FilterItem( - value: () => tradeFilterStore.displaySimpleSwap, - caption: ExchangeProviderDescription.simpleSwap.title, - onChanged: () => - tradeFilterStore.toggleDisplayExchange(ExchangeProviderDescription.simpleSwap)), - FilterItem( - value: () => tradeFilterStore.displayTrocador, - caption: ExchangeProviderDescription.trocador.title, - onChanged: () => - tradeFilterStore.toggleDisplayExchange(ExchangeProviderDescription.trocador)), - FilterItem( - value: () => tradeFilterStore.displayExolix, - caption: ExchangeProviderDescription.exolix.title, - onChanged: () => - tradeFilterStore.toggleDisplayExchange(ExchangeProviderDescription.exolix)), - FilterItem( - value: () => tradeFilterStore.displayChainflip, - caption: ExchangeProviderDescription.chainflip.title, - onChanged: () => - tradeFilterStore.toggleDisplayExchange(ExchangeProviderDescription.chainflip)), - FilterItem( - value: () => tradeFilterStore.displayThorChain, - caption: ExchangeProviderDescription.thorChain.title, - onChanged: () => - tradeFilterStore.toggleDisplayExchange(ExchangeProviderDescription.thorChain)), - FilterItem( - value: () => tradeFilterStore.displayLetsExchange, - caption: ExchangeProviderDescription.letsExchange.title, - onChanged: () => tradeFilterStore - .toggleDisplayExchange(ExchangeProviderDescription.letsExchange)), - FilterItem( - value: () => tradeFilterStore.displayStealthEx, - caption: ExchangeProviderDescription.stealthEx.title, - onChanged: () => - tradeFilterStore.toggleDisplayExchange(ExchangeProviderDescription.stealthEx)), - FilterItem( - value: () => tradeFilterStore.displayXOSwap, - caption: ExchangeProviderDescription.xoSwap.title, - onChanged: () => - tradeFilterStore.toggleDisplayExchange(ExchangeProviderDescription.xoSwap)), - FilterItem( - value: () => tradeFilterStore.displaySwapTrade, - caption: ExchangeProviderDescription.swapTrade.title, - onChanged: () => - tradeFilterStore.toggleDisplayExchange(ExchangeProviderDescription.swapTrade)), - FilterItem( - value: () => tradeFilterStore.displaySwapXyz, - caption: ExchangeProviderDescription.swapsXyz.title, - onChanged: () => - tradeFilterStore.toggleDisplayExchange(ExchangeProviderDescription.swapsXyz)), - FilterItem( - value: () => tradeFilterStore.displayNearIntents, - caption: ExchangeProviderDescription.nearIntents.title, - onChanged: () => tradeFilterStore - .toggleDisplayExchange(ExchangeProviderDescription.nearIntents)), - ] - }, + value: () => transactionFilterStore.displaySilentPayments, + caption: S.current.silent_payments, + onChanged: transactionFilterStore.toggleSilentPayments, + ), + SwapFilterItem( + enabledProviders: () => tradeFilterStore.enabledProviders, + allEnabled: () => tradeFilterStore.displayAllTrades, + value: () => tradeFilterStore.displayAllTrades, + onChanged: () => + tradeFilterStore.toggleDisplayExchange(ExchangeProviderDescription.all)), + FilterItem( + value: () => orderFilterStore.displayCakePay, + caption: 'Cake Pay', + onChanged: () => + orderFilterStore.toggleDisplayOrder(OrderProviderDescription.cakePay)), + ], + exchangeFilterItems = [ + SwapProviderFilterItem( + providerDescription: ExchangeProviderDescription.changeNow, + value: () => tradeFilterStore.displayChangeNow, + onChanged: () => + tradeFilterStore.toggleDisplayExchange(ExchangeProviderDescription.changeNow)), + SwapProviderFilterItem( + providerDescription: ExchangeProviderDescription.sideShift, + value: () => tradeFilterStore.displaySideShift, + onChanged: () => + tradeFilterStore.toggleDisplayExchange(ExchangeProviderDescription.sideShift)), + SwapProviderFilterItem( + providerDescription: ExchangeProviderDescription.simpleSwap, + value: () => tradeFilterStore.displaySimpleSwap, + onChanged: () => + tradeFilterStore.toggleDisplayExchange(ExchangeProviderDescription.simpleSwap)), + SwapProviderFilterItem( + providerDescription: ExchangeProviderDescription.trocador, + value: () => tradeFilterStore.displayTrocador, + onChanged: () => + tradeFilterStore.toggleDisplayExchange(ExchangeProviderDescription.trocador)), + SwapProviderFilterItem( + providerDescription: ExchangeProviderDescription.exolix, + value: () => tradeFilterStore.displayExolix, + onChanged: () => + tradeFilterStore.toggleDisplayExchange(ExchangeProviderDescription.exolix)), + SwapProviderFilterItem( + providerDescription: ExchangeProviderDescription.chainflip, + value: () => tradeFilterStore.displayChainflip, + onChanged: () => + tradeFilterStore.toggleDisplayExchange(ExchangeProviderDescription.chainflip)), + SwapProviderFilterItem( + providerDescription: ExchangeProviderDescription.thorChain, + value: () => tradeFilterStore.displayThorChain, + onChanged: () => + tradeFilterStore.toggleDisplayExchange(ExchangeProviderDescription.thorChain)), + SwapProviderFilterItem( + providerDescription: ExchangeProviderDescription.letsExchange, + value: () => tradeFilterStore.displayLetsExchange, + onChanged: () => + tradeFilterStore.toggleDisplayExchange(ExchangeProviderDescription.letsExchange)), + SwapProviderFilterItem( + providerDescription: ExchangeProviderDescription.stealthEx, + value: () => tradeFilterStore.displayStealthEx, + onChanged: () => + tradeFilterStore.toggleDisplayExchange(ExchangeProviderDescription.stealthEx)), + SwapProviderFilterItem( + providerDescription: ExchangeProviderDescription.xoSwap, + value: () => tradeFilterStore.displayXOSwap, + onChanged: () => + tradeFilterStore.toggleDisplayExchange(ExchangeProviderDescription.xoSwap)), + SwapProviderFilterItem( + providerDescription: ExchangeProviderDescription.swapTrade, + value: () => tradeFilterStore.displaySwapTrade, + onChanged: () => + tradeFilterStore.toggleDisplayExchange(ExchangeProviderDescription.swapTrade)), + SwapProviderFilterItem( + providerDescription: ExchangeProviderDescription.swapsXyz, + value: () => tradeFilterStore.displaySwapXyz, + onChanged: () => + tradeFilterStore.toggleDisplayExchange(ExchangeProviderDescription.swapsXyz)), + SwapProviderFilterItem( + providerDescription: ExchangeProviderDescription.nearIntents, + value: () => tradeFilterStore.displayNearIntents, + onChanged: () => + tradeFilterStore.toggleDisplayExchange(ExchangeProviderDescription.nearIntents)), + ], subname = '', name = appStore.wallet!.name, type = appStore.wallet!.type, @@ -406,6 +399,15 @@ abstract class DashboardViewModelBase with Store { return false; } + @action + void changeAllFilterItems(bool value) { + for (final item in filterItems) { + if (item.value() != value) { + item.onChanged(); + } + } + } + @action Future loadCardDesigns() async { final accountStyleSettings = @@ -719,7 +721,10 @@ abstract class DashboardViewModelBase with Store { static const shortHistoryLength = 3; @computed - List get itemsShort => items.where((item)=>item is! DateSectionItem).take(3).toList(); + List get itemsShort => items + .where((item) => item is! DateSectionItem) + .take(shortHistoryLength) + .toList(); @observable WalletBase, TransactionInfo> wallet; @@ -1129,7 +1134,11 @@ abstract class DashboardViewModelBase with Store { PayjoinTransactionsStore payjoinTransactionsStore; - Map> filterItems; + // Map> filterItems; + + List filterItems; + + List exchangeFilterItems; bool get isBuyEnabled => settingsStore.isBitcoinBuyEnabled; diff --git a/lib/view_model/dashboard/date_section_item.dart b/lib/view_model/dashboard/date_section_item.dart index 75250a7ea..5bc607dcf 100644 --- a/lib/view_model/dashboard/date_section_item.dart +++ b/lib/view_model/dashboard/date_section_item.dart @@ -1,8 +1,36 @@ +import 'package:cake_wallet/generated/i18n.dart'; import 'package:cake_wallet/view_model/dashboard/action_list_item.dart'; class DateSectionItem extends ActionListItem { DateSectionItem(this.date, {required super.key}); - + @override final DateTime date; -} \ No newline at end of file +} + +abstract class SpecificDateSectionItem extends DateSectionItem { + SpecificDateSectionItem(super.date, {required super.key}); + + String get text; +} + +class Last30daysTransactionItem extends SpecificDateSectionItem { + Last30daysTransactionItem(super.date, {required super.key}); + + @override + String get text => S.current.last_30_days; +} + +class Last7daysTransactionItem extends SpecificDateSectionItem { + Last7daysTransactionItem(super.date, {required super.key}); + + @override + String get text => S.current.last_7_days; +} + +class TodayTransactionItem extends SpecificDateSectionItem { + TodayTransactionItem(super.date, {required super.key}); + + @override + String get text => S.current.today; +} diff --git a/lib/view_model/dashboard/filter_item.dart b/lib/view_model/dashboard/filter_item.dart index 0944e899c..ffd0a35a3 100644 --- a/lib/view_model/dashboard/filter_item.dart +++ b/lib/view_model/dashboard/filter_item.dart @@ -1,4 +1,4 @@ -import 'package:mobx/mobx.dart'; +import 'package:cake_wallet/exchange/exchange_provider_description.dart'; class FilterItem { FilterItem({ @@ -9,4 +9,32 @@ class FilterItem { bool Function() value; String caption; Function onChanged; +} + +class SwapFilterItem extends FilterItem { + SwapFilterItem({ + required this.enabledProviders, + required this.allEnabled, + required super.value, + super.caption = "Swap", + required super.onChanged, + }); + + int Function() enabledProviders; + bool Function() allEnabled; +} + + +class SwapProviderFilterItem extends FilterItem { + SwapProviderFilterItem({ + required super.value, + required super.onChanged, + required this.providerDescription, + super.caption = "" +}); + + @override + String get caption => providerDescription.title; + + final ExchangeProviderDescription providerDescription; } \ No newline at end of file diff --git a/lib/view_model/dashboard/formatted_item_list.dart b/lib/view_model/dashboard/formatted_item_list.dart index 04464ca89..2348222c5 100644 --- a/lib/view_model/dashboard/formatted_item_list.dart +++ b/lib/view_model/dashboard/formatted_item_list.dart @@ -1,45 +1,82 @@ -import 'package:cake_wallet/view_model/dashboard/action_list_item.dart'; -import 'package:cake_wallet/view_model/dashboard/date_section_item.dart'; -import 'package:flutter/foundation.dart'; +import "package:cake_wallet/view_model/dashboard/action_list_item.dart"; +import "package:cake_wallet/view_model/dashboard/date_section_item.dart"; +import "package:flutter/foundation.dart"; + +enum _DateBucket { recent, last7Days, last30Days, byMonth } List formattedItemsList(List items) { final formattedList = []; - DateTime? lastDate; items.sort((a, b) => b.date.compareTo(a.date)); - for (var i = 0; i < items.length; i++) { - final transaction = items[i]; + final now = DateTime.now(); + final last24hThreshold = now.subtract(const Duration(hours: 24)); + final last7daysThreshold = now.subtract(const Duration(days: 7)); + final last30daysThreshold = now.subtract(const Duration(days: 30)); - if (lastDate == null) { - lastDate = transaction.date; - formattedList.add( - DateSectionItem( - transaction.date, - key: ValueKey('date_section_item_${transaction.date.microsecondsSinceEpoch}_key'), - ), - ); - formattedList.add(transaction); - continue; + _DateBucket? lastBucket; + DateTime? lastMonthDate; + + for (final transaction in items) { + final date = transaction.date; + + final bucket = date.isAfter(last24hThreshold) + ? _DateBucket.recent + : date.isAfter(last7daysThreshold) + ? _DateBucket.last7Days + : date.isAfter(last30daysThreshold) + ? _DateBucket.last30Days + : _DateBucket.byMonth; + + switch (bucket) { + case _DateBucket.recent: + if (lastBucket != _DateBucket.recent) { + formattedList.add( + TodayTransactionItem( + date, + key: const ValueKey("last_7_days_section_item_key"), + ), + ); + } + break; + case _DateBucket.last7Days: + if (lastBucket != _DateBucket.last7Days) { + formattedList.add( + Last7daysTransactionItem( + date, + key: const ValueKey("last_7_days_section_item_key"), + ), + ); + } + break; + case _DateBucket.last30Days: + if (lastBucket != _DateBucket.last30Days) { + formattedList.add( + Last30daysTransactionItem( + date, + key: const ValueKey("last_30_days_section_item_key"), + ), + ); + } + break; + case _DateBucket.byMonth: + final isNewMonth = lastMonthDate == null || + lastMonthDate.year != date.year || + lastMonthDate.month != date.month; + if (isNewMonth) { + lastMonthDate = date; + formattedList.add( + DateSectionItem( + date, + key: ValueKey("date_section_item_${date.year}_${date.month}_key"), + ), + ); + } + break; } - final isCurrentDay = lastDate.year == transaction.date.year && - lastDate.month == transaction.date.month && - lastDate.day == transaction.date.day; - - if (isCurrentDay) { - formattedList.add(transaction); - continue; - } - - lastDate = transaction.date; - formattedList.add( - DateSectionItem( - transaction.date, - key: ValueKey('date_section_item_${transaction.date.microsecondsSinceEpoch}_key'), - ), - ); + lastBucket = bucket; formattedList.add(transaction); } return formattedList; -} \ No newline at end of file +} diff --git a/res/values/strings_en.arb b/res/values/strings_en.arb index b7f1832fd..19a6820ea 100644 --- a/res/values/strings_en.arb +++ b/res/values/strings_en.arb @@ -53,6 +53,7 @@ "alert_notice": "Notice", "all": "ALL", "all_coins": "All Coins", + "all_pascal_case": "All", "all_trades": "All trades", "all_transactions": "All transactions", "alphabetical": "Alphabetical", @@ -487,6 +488,7 @@ "file_saved": "File saved", "fill_code": "Please fill in the verification code provided to your email", "filter_by": "Filter by", + "filter_swap_providers": "Filter Swap Providers", "filters": "Filters", "finding_provider": "Finding provider", "first_wallet_text": "Awesome wallet for Monero, Bitcoin, Ethereum, Litecoin, and Haven", @@ -568,6 +570,7 @@ "label": "Label", "label_address": "Label address", "last_30_days": "Last 30 days", + "last_7_days": "Last 7 days", "learn_more": "Learn More", "ledger_connection_error": "Failed to connect to your Ledger. Please try again.", "ledger_error_device_locked": "The Ledger is locked", @@ -682,6 +685,7 @@ "no_id_needed": "No ID needed!", "no_id_required": "No ID required. Top up and spend anywhere", "no_providers_available": "No providers available", + "no_providers_selected": "No Providers Selected", "no_relay_on_domain": "There isn't a relay for user's domain or the relay is unavailable. Please choose a relay to use.", "no_relays": "No relays", "no_relays_message": "We found a Nostr NIP-05 record for this user, but it does not contain any relays. Please instruct the recipient to add relays to their Nostr record.", @@ -871,6 +875,7 @@ "rescan": "Rescan", "resend_code": "Please resend it", "reset": "Reset", + "reset_filters": "Reset Filters", "reset_password": "Reset Password", "restore": "Restore", "restore_active_seed": "Active seed", @@ -1285,6 +1290,7 @@ "tx_wrong_balance_exception": "You do not have enough ${currency} to send this amount.", "tx_wrong_balance_with_amount_exception": "You do not have enough ${currency} to send the total amount of ${amount}", "tx_zero_fee_exception": "Cannot send transaction with 0 fee. Try increasing the rate or checking your connection for latest estimates.", + "type": "Type", "unavailable_balance": "Unavailable balance", "unavailable_balance_description": "Unavailable Balance: This total includes funds that are locked in pending transactions and those you have actively frozen in your Coin Control settings. Locked balances will become available once their respective transactions are completed, while frozen balances remain inaccessible for transactions until you decide to unfreeze them.", "unconfirmed": "Unconfirmed Balance", From f7d80c62a24e96cfe3d4aba364f259f3d03959a8 Mon Sep 17 00:00:00 2001 From: Robert Malikowski Date: Fri, 26 Jun 2026 10:36:57 +0200 Subject: [PATCH 03/48] fix send/receive --- .../dashboard/dashboard_view_model.dart | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/lib/view_model/dashboard/dashboard_view_model.dart b/lib/view_model/dashboard/dashboard_view_model.dart index 02c373f0a..bb34a1345 100644 --- a/lib/view_model/dashboard/dashboard_view_model.dart +++ b/lib/view_model/dashboard/dashboard_view_model.dart @@ -101,14 +101,14 @@ abstract class DashboardViewModelBase with Store { // value: () => transactionFilterStore.displayAll, // caption: S.current.all_transactions, // onChanged: transactionFilterStore.toggleAll), - FilterItem( - value: () => transactionFilterStore.displayIncoming, - caption: S.current.send, - onChanged: transactionFilterStore.toggleIncoming), FilterItem( value: () => transactionFilterStore.displayOutgoing, - caption: S.current.receive, + caption: S.current.send, onChanged: transactionFilterStore.toggleOutgoing), + FilterItem( + value: () => transactionFilterStore.displayIncoming, + caption: S.current.receive, + onChanged: transactionFilterStore.toggleIncoming), if (appStore.wallet!.type == WalletType.bitcoin) FilterItem( value: () => transactionFilterStore.displaySilentPayments, @@ -118,7 +118,7 @@ abstract class DashboardViewModelBase with Store { SwapFilterItem( enabledProviders: () => tradeFilterStore.enabledProviders, allEnabled: () => tradeFilterStore.displayAllTrades, - value: () => tradeFilterStore.displayAllTrades, + value: () => tradeFilterStore.enabledProviders>0, onChanged: () => tradeFilterStore.toggleDisplayExchange(ExchangeProviderDescription.all)), FilterItem( @@ -406,6 +406,11 @@ abstract class DashboardViewModelBase with Store { item.onChanged(); } } + for (final item in exchangeFilterItems) { + if (item.value() != value) { + item.onChanged(); + } + } } @action From 681009679512345bab11fff69bae475ab2d242f8 Mon Sep 17 00:00:00 2001 From: Robert Malikowski Date: Fri, 26 Jun 2026 10:37:08 +0200 Subject: [PATCH 04/48] fix today threshold --- lib/view_model/dashboard/formatted_item_list.dart | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/view_model/dashboard/formatted_item_list.dart b/lib/view_model/dashboard/formatted_item_list.dart index 2348222c5..45dfb663d 100644 --- a/lib/view_model/dashboard/formatted_item_list.dart +++ b/lib/view_model/dashboard/formatted_item_list.dart @@ -9,7 +9,7 @@ List formattedItemsList(List items) { items.sort((a, b) => b.date.compareTo(a.date)); final now = DateTime.now(); - final last24hThreshold = now.subtract(const Duration(hours: 24)); + final todayTreshold = DateTime(now.year, now.month, now.day); final last7daysThreshold = now.subtract(const Duration(days: 7)); final last30daysThreshold = now.subtract(const Duration(days: 30)); @@ -19,7 +19,7 @@ List formattedItemsList(List items) { for (final transaction in items) { final date = transaction.date; - final bucket = date.isAfter(last24hThreshold) + final bucket = date.isAfter(todayTreshold) ? _DateBucket.recent : date.isAfter(last7daysThreshold) ? _DateBucket.last7Days @@ -33,7 +33,7 @@ List formattedItemsList(List items) { formattedList.add( TodayTransactionItem( date, - key: const ValueKey("last_7_days_section_item_key"), + key: const ValueKey("today_section_item_key"), ), ); } From ba0be28da81fe1cf43090d1b375dddb3bac67d67 Mon Sep 17 00:00:00 2001 From: Robert Malikowski Date: Fri, 26 Jun 2026 10:37:16 +0200 Subject: [PATCH 05/48] fix swap filter checkbox --- .../widgets/coins_page/assets_history/history_filters_page.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/new-ui/widgets/coins_page/assets_history/history_filters_page.dart b/lib/new-ui/widgets/coins_page/assets_history/history_filters_page.dart index 3a71a86cb..072f11015 100644 --- a/lib/new-ui/widgets/coins_page/assets_history/history_filters_page.dart +++ b/lib/new-ui/widgets/coins_page/assets_history/history_filters_page.dart @@ -52,7 +52,7 @@ class HistoryFiltersPage extends StatelessWidget { if (item is SwapFilterItem) { final String subtitle; final Color subtitleColor; - if (item.value()) { + if (dashboardViewModel.tradeFilterStore.displayAllTrades) { subtitle = S.of(context).manage_providers; subtitleColor = Theme.of(context).colorScheme.onSurfaceVariant; } else if (dashboardViewModel.tradeFilterStore.enabledProviders == 0) { From 52b014aac2d44c359f5e6a8be649db972faf3905 Mon Sep 17 00:00:00 2001 From: Robert Malikowski Date: Fri, 26 Jun 2026 10:37:28 +0200 Subject: [PATCH 06/48] add border around filters button --- .../widgets/coins_page/assets_history/history_modal.dart | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/lib/new-ui/widgets/coins_page/assets_history/history_modal.dart b/lib/new-ui/widgets/coins_page/assets_history/history_modal.dart index 9b93a9d0c..864c7fec6 100644 --- a/lib/new-ui/widgets/coins_page/assets_history/history_modal.dart +++ b/lib/new-ui/widgets/coins_page/assets_history/history_modal.dart @@ -86,7 +86,11 @@ class HistoryModal extends StatelessWidget { }, child: Material( color: Colors.transparent, - child: SizedBox( + 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( From 117f8e922a8506f14e6a236147814a4c24edc2a9 Mon Sep 17 00:00:00 2001 From: Robert Malikowski Date: Fri, 26 Jun 2026 10:37:51 +0200 Subject: [PATCH 07/48] fix observer for history section --- .../assets_history/history_section.dart | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/lib/new-ui/widgets/coins_page/assets_history/history_section.dart b/lib/new-ui/widgets/coins_page/assets_history/history_section.dart index fd2e08145..bb2016b5b 100644 --- a/lib/new-ui/widgets/coins_page/assets_history/history_section.dart +++ b/lib/new-ui/widgets/coins_page/assets_history/history_section.dart @@ -34,12 +34,14 @@ class HistorySection extends StatelessWidget { @override Widget build(BuildContext context) { - final items = short ? dashboardViewModel.itemsShort : dashboardViewModel.items; return SliverPadding( padding: EdgeInsets.only(left: 16.0, right: 16, top: short && roundedTopSection ? 18 : 0), sliver: Observer( - builder: (_) => (items.isEmpty) + builder: (_) { + final items = short ? dashboardViewModel.itemsShort : dashboardViewModel.items; + + return (items.isEmpty) ? SliverPadding( padding: EdgeInsets.only(top: 24), sliver: SliverToBoxAdapter( @@ -203,7 +205,8 @@ class HistorySection extends StatelessWidget { return Text(item.runtimeType.toString()); }), ), - ), + ); + }, )); } @@ -237,15 +240,15 @@ class HistorySection extends StatelessWidget { if (daysAgo < 7) { final weekday = DateFormat.EEEE().format(date); - return '$weekday, $timeStr'; + return "$weekday, $timeStr"; } if (date.year == now.year) { final dayMonth = DateFormat("d MMMM").format(date); - return '$dayMonth, $timeStr'; + return "$dayMonth, $timeStr"; } - final full = DateFormat("d MMMM yyyy").format(date); - return '$full, $timeStr'; + final full = DateFormat("d MMM yyyy").format(date); + return "$full, $timeStr"; } } From eff46638d9ba26b9bf3b4decb6da71bf143332c4 Mon Sep 17 00:00:00 2001 From: Robert Malikowski Date: Fri, 26 Jun 2026 13:25:55 +0200 Subject: [PATCH 08/48] locale fix --- .../assets_history_section.dart | 35 ++++++++--- .../assets_history/assets_top_bar.dart | 58 ++++--------------- .../assets_history/history_section.dart | 24 ++++---- 3 files changed, 48 insertions(+), 69 deletions(-) diff --git a/lib/new-ui/widgets/coins_page/assets_history/assets_history_section.dart b/lib/new-ui/widgets/coins_page/assets_history/assets_history_section.dart index ecc163092..81fad913d 100644 --- a/lib/new-ui/widgets/coins_page/assets_history/assets_history_section.dart +++ b/lib/new-ui/widgets/coins_page/assets_history/assets_history_section.dart @@ -4,6 +4,7 @@ import 'package:cake_wallet/new-ui/widgets/coins_page/assets_history/assets_top_ import 'package:cake_wallet/new-ui/widgets/coins_page/assets_history/history_modal.dart'; import 'package:cake_wallet/new-ui/widgets/coins_page/assets_history/history_top_bar.dart'; import 'package:cake_wallet/reactions/wallet_connect.dart'; +import 'package:cake_wallet/routes.dart'; import 'package:cake_wallet/src/screens/dashboard/pages/nft_listing_page.dart'; import 'package:cake_wallet/view_model/dashboard/dashboard_view_model.dart'; import 'package:cake_wallet/view_model/dashboard/nft_view_model.dart'; @@ -13,11 +14,20 @@ 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 { @@ -36,24 +46,31 @@ class _AssetsHistorySectionState extends State { void reloadTabs() { final oldTabLength = tabs.length; + final hasAssetsTab = widget.dashboardViewModel.balanceViewModel.isHomeScreenSettingsEnabled || (widget.dashboardViewModel.hasMweb && widget.dashboardViewModel.mwebEnabled); + 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, - )), + ), AssetsHistorySectionActionButton(S.current.tokens, "assets/new-ui/options_slider.svg", (){ + Navigator.of(context).pushNamed( + Routes.homeSettings, + arguments: widget.dashboardViewModel.balanceViewModel, + ); + })), AssetsHistorySectionTab( S.current.history, HistorySection( detailsAsPage: false, - roundedTopSection: tabs.length > 1, + roundedTopSection: hasAssetsTab || hasNftTab, dashboardViewModel: widget.dashboardViewModel, short: true, - )), - if (isNFTACtivatedChain(widget.dashboardViewModel.wallet.type, - chainId: widget.dashboardViewModel.wallet.chainId)) - AssetsHistorySectionTab(S.current.nfts, NFTListingPage(nftViewModel: widget.nftViewModel)) + ), 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(() { @@ -81,7 +98,7 @@ class _AssetsHistorySectionState extends State { AssetsTopBar( onTransactionHistoryOpened: () => openHistoryModal(context), dashboardViewModel: widget.dashboardViewModel, - tabs: tabs.map((item) => item.title).toList(), + tabs: tabs, onTabChange: (index) { setState(() { _selectedTab = index; diff --git a/lib/new-ui/widgets/coins_page/assets_history/assets_top_bar.dart b/lib/new-ui/widgets/coins_page/assets_history/assets_top_bar.dart index a99e8f137..67fe4c9a3 100644 --- a/lib/new-ui/widgets/coins_page/assets_history/assets_top_bar.dart +++ b/lib/new-ui/widgets/coins_page/assets_history/assets_top_bar.dart @@ -1,9 +1,5 @@ -import 'package:cake_wallet/core/csv_export_service.dart'; -import 'package:cake_wallet/generated/i18n.dart'; +import 'package:cake_wallet/new-ui/widgets/coins_page/assets_history/assets_history_section.dart'; import 'package:cake_wallet/new-ui/widgets/line_tab_switcher.dart'; -import 'package:cake_wallet/routes.dart'; -import 'package:cake_wallet/src/screens/dashboard/widgets/filter_widget.dart'; -import 'package:cake_wallet/utils/show_pop_up.dart'; import 'package:cake_wallet/src/widgets/cake_image_widget.dart'; import 'package:cake_wallet/view_model/dashboard/dashboard_view_model.dart'; import 'package:flutter/material.dart'; @@ -20,13 +16,11 @@ class AssetsTopBar extends StatelessWidget { final void Function(int) onTabChange; final VoidCallback onTransactionHistoryOpened; final int selectedTab; - final List tabs; + final List tabs; final DashboardViewModel dashboardViewModel; @override Widget build(BuildContext context) { - final settingsButtonText = _getSettingsButtonText(); - final hasTokenSettingsButton = settingsButtonText != null; return SliverToBoxAdapter( child: Padding( @@ -36,7 +30,7 @@ class AssetsTopBar extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ LineTabSwitcher( - tabs: tabs, + tabs: tabs.map((item)=>item.title).toList(), onTabChange: onTabChange, selectedTab: selectedTab, ), @@ -57,19 +51,13 @@ class AssetsTopBar extends StatelessWidget { children: [ Opacity( - opacity: hasTokenSettingsButton ? 1 : 0, + opacity: tabs[selectedTab].actionButton != null ? 1 : 0, child: GestureDetector( onTap: () { - if (tabs[selectedTab] == S.of(context).assets) { - Navigator.of(context).pushNamed( - Routes.homeSettings, - arguments: dashboardViewModel.balanceViewModel, - ); - } else if (tabs[selectedTab] == S.of(context).history) { - - onTransactionHistoryOpened(); - } - }, + if(tabs[selectedTab].actionButton != null) { + tabs[selectedTab].actionButton?.onPressed(); + } + }, child: Container( height: 40, decoration: BoxDecoration( @@ -82,13 +70,13 @@ class AssetsTopBar extends StatelessWidget { spacing: 6, children: [ - if ((settingsButtonText ?? "").isNotEmpty) + if ((tabs[selectedTab].actionButton?.title ?? "").isNotEmpty) Text( - settingsButtonText ?? "", + tabs[selectedTab].actionButton?.title ?? "", style: TextStyle(color: Theme.of(context).colorScheme.primary), ), CakeImageWidget( - imageUrl: _getSettingsButtonIconPath(), + imageUrl: tabs[selectedTab].actionButton?.iconPath, colorFilter: ColorFilter.mode( Theme.of(context).colorScheme.primary, BlendMode.srcIn)), ], @@ -106,28 +94,4 @@ class AssetsTopBar extends StatelessWidget { ); } - String? _getSettingsButtonIconPath() { - if (tabs[selectedTab] == S.current.history) { - return "assets/new-ui/arrow_right.svg"; - } - - if (tabs[selectedTab] == S.current.assets && - dashboardViewModel.balanceViewModel.isHomeScreenSettingsEnabled) { - return "assets/new-ui/options_slider.svg"; - } - - return null; - } - - String? _getSettingsButtonText() { - if (tabs[selectedTab] == S.current.assets && - dashboardViewModel.balanceViewModel.isHomeScreenSettingsEnabled) { - return S.current.tokens; - } - - if (tabs[selectedTab] == S.current.history) { - return S.current.all_pascal_case; - } - return null; - } } diff --git a/lib/new-ui/widgets/coins_page/assets_history/history_section.dart b/lib/new-ui/widgets/coins_page/assets_history/history_section.dart index bb2016b5b..56b07cab3 100644 --- a/lib/new-ui/widgets/coins_page/assets_history/history_section.dart +++ b/lib/new-ui/widgets/coins_page/assets_history/history_section.dart @@ -1,5 +1,3 @@ -import 'dart:math'; - import 'package:cake_wallet/di.dart'; import 'package:cake_wallet/generated/i18n.dart'; import 'package:cake_wallet/new-ui/widgets/coins_page/assets_history/anonpay_history_tile.dart'; @@ -9,7 +7,6 @@ import 'package:cake_wallet/new-ui/widgets/coins_page/assets_history/history_tra import 'package:cake_wallet/new-ui/widgets/coins_page/assets_history/payjoin_history_tile.dart'; import 'package:cake_wallet/new-ui/widgets/coins_page/assets_history/transaction_details_modal.dart'; import 'package:cake_wallet/routes.dart'; -import 'package:cake_wallet/utils/date_formatter.dart'; import 'package:cake_wallet/view_model/dashboard/anonpay_transaction_list_item.dart'; import 'package:cake_wallet/view_model/dashboard/dashboard_view_model.dart'; import 'package:cake_wallet/view_model/dashboard/date_section_item.dart'; @@ -39,6 +36,7 @@ class HistorySection extends StatelessWidget { padding: EdgeInsets.only(left: 16.0, right: 16, top: short && roundedTopSection ? 18 : 0), sliver: Observer( builder: (_) { + final localeName = Localizations.localeOf(context).toString(); final items = short ? dashboardViewModel.itemsShort : dashboardViewModel.items; return (items.isEmpty) @@ -100,7 +98,7 @@ class HistorySection extends StatelessWidget { }, child: HistoryTile( title: item.formattedTitle + transactionType, - date: _formatTransactionDate(item.date), + date: _formatTransactionDate(item.date, localeName), amount: item.formattedCryptoAmount, amountFiat: item.formattedFiatAmount, hasTokens: item.hasTokens, @@ -128,7 +126,7 @@ class HistorySection extends StatelessWidget { from: tradeFrom, to: tradeTo, provider: trade.provider, - date: _formatTransactionDate(item.trade.createdAt ?? DateTime.now()), + date: _formatTransactionDate(item.trade.createdAt ?? DateTime.now(), localeName), amount: trade.amountFormatted(), receiveAmount: trade.receiveAmountFormatted(), roundedBottom: roundedBottom, @@ -154,7 +152,7 @@ class HistorySection extends StatelessWidget { onTap: () => Navigator.of(context) .pushNamed(Routes.orderDetails, arguments: item.order), child: HistoryOrderTile( - date: _formatTransactionDate(item.order.createdAt), + date: _formatTransactionDate(item.order.createdAt, localeName), amount: item.orderFormattedAmount, amountFiat: "USD 0.00", roundedBottom: roundedBottom, @@ -171,7 +169,7 @@ class HistorySection extends StatelessWidget { arguments: [item.sessionId, item.transaction], ), child: PayjoinHistoryTile( - createdAt: _formatTransactionDate(session.inProgressSince!), + createdAt: _formatTransactionDate(session.inProgressSince!, localeName), amount: dashboardViewModel.appStore.amountParsingProxy .getDisplayCryptoString( session.amount.toInt(), CryptoCurrency.btc), @@ -190,7 +188,7 @@ class HistorySection extends StatelessWidget { .pushNamed(Routes.anonPayDetailsPage, arguments: transactionInfo), child: AnonpayHistoryTile( provider: transactionInfo.provider, - createdAt: _formatTransactionDate(transactionInfo.createdAt), + createdAt: _formatTransactionDate(transactionInfo.createdAt, localeName), amount: transactionInfo.fiatAmount?.toString() ?? (transactionInfo.amountTo?.toString() ?? ''), currency: transactionInfo.fiatAmount != null @@ -220,8 +218,8 @@ class HistorySection extends StatelessWidget { } } - String _formatTransactionDate(DateTime date) { - final time = DateFormat.Hm(); + String _formatTransactionDate(DateTime date, String localeName) { + final time = DateFormat.Hm(localeName); final now = DateTime.now(); final today = DateTime(now.year, now.month, now.day); @@ -239,16 +237,16 @@ class HistorySection extends StatelessWidget { } if (daysAgo < 7) { - final weekday = DateFormat.EEEE().format(date); + final weekday = DateFormat.EEEE(localeName).format(date); return "$weekday, $timeStr"; } if (date.year == now.year) { - final dayMonth = DateFormat("d MMMM").format(date); + final dayMonth = DateFormat("d MMMM", localeName).format(date); return "$dayMonth, $timeStr"; } - final full = DateFormat("d MMM yyyy").format(date); + final full = DateFormat("d MMM yyyy", localeName).format(date); return "$full, $timeStr"; } } From 04d22594c8dea3a81f9a4d26bc4210437888e882 Mon Sep 17 00:00:00 2001 From: Robert Malikowski Date: Wed, 1 Jul 2026 17:24:15 +0200 Subject: [PATCH 09/48] add spanish for last 7 days --- res/values/strings_es.arb | 1 + 1 file changed, 1 insertion(+) diff --git a/res/values/strings_es.arb b/res/values/strings_es.arb index 3b5560617..91c41c6f2 100644 --- a/res/values/strings_es.arb +++ b/res/values/strings_es.arb @@ -555,6 +555,7 @@ "keys": "Claves", "label": "Etiqueta", "label_address": "Etiquetar dirección", + "last_7_days": "Últimos 7 días", "last_30_days": "Últimos 30 días", "learn_more": "Más información", "ledger_connection_error": "No se pudo conectar a tu Ledger. Inténtalo de nuevo.", From ddd66b08eab342c73e8978d8948a1c21b812df38 Mon Sep 17 00:00:00 2001 From: Robert Malikowski Date: Wed, 1 Jul 2026 17:24:38 +0200 Subject: [PATCH 10/48] properly translate month names in history --- .../widgets/coins_page/assets_history/history_section.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/new-ui/widgets/coins_page/assets_history/history_section.dart b/lib/new-ui/widgets/coins_page/assets_history/history_section.dart index 212b7e2cd..2bdd715ac 100644 --- a/lib/new-ui/widgets/coins_page/assets_history/history_section.dart +++ b/lib/new-ui/widgets/coins_page/assets_history/history_section.dart @@ -143,7 +143,7 @@ class HistorySection extends StatelessWidget { } else if (item is DateSectionItem) { return Padding( padding: EdgeInsets.only(left: 8.0, bottom: 8.0, top: topPadding), - child: Text(DateFormat("MMMM yyyy").format(item.date), + child: Text(DateFormat("MMMM yyyy", localeName).format(item.date), style: TextStyle( color: Theme.of(context).colorScheme.onSurfaceVariant))); } else if (item is OrderListItem) { From 63651aa39c82fc6c8f63ef22d89f9096176fcc91 Mon Sep 17 00:00:00 2001 From: Robert Malikowski Date: Wed, 1 Jul 2026 17:24:56 +0200 Subject: [PATCH 11/48] fix line tab switcher updates --- lib/new-ui/widgets/line_tab_switcher.dart | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/new-ui/widgets/line_tab_switcher.dart b/lib/new-ui/widgets/line_tab_switcher.dart index b20cd23f7..15927aff6 100644 --- a/lib/new-ui/widgets/line_tab_switcher.dart +++ b/lib/new-ui/widgets/line_tab_switcher.dart @@ -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 { 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; From 46cda778d8a1234eda586f7b3e72a23520e5ef5a Mon Sep 17 00:00:00 2001 From: Robert Malikowski Date: Wed, 1 Jul 2026 17:25:13 +0200 Subject: [PATCH 12/48] fix date format in tx details --- lib/view_model/transaction_details_view_model.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/view_model/transaction_details_view_model.dart b/lib/view_model/transaction_details_view_model.dart index 7199ed1a9..18c3c9a2e 100644 --- a/lib/view_model/transaction_details_view_model.dart +++ b/lib/view_model/transaction_details_view_model.dart @@ -30,6 +30,7 @@ import 'package:cw_core/transaction_direction.dart'; import 'package:cw_core/transaction_priority.dart'; import 'package:flutter/foundation.dart'; import 'package:hive/hive.dart'; +import 'package:intl/intl.dart'; import 'package:mobx/mobx.dart'; import 'package:url_launcher/url_launcher.dart'; @@ -77,8 +78,7 @@ class TxDetailRowDefinition { TxDetailRowDefinition( keyString: "standard_list_item_transaction_details_date_key", title: S.current.transaction_details_date, - valueGetter: (vm) => DateFormatter.withCurrentLocal().format(vm.transactionInfo.date)), - + valueGetter: (vm) => DateFormat("d MMMM yyyy, HH:mm", vm._appStore.settingsStore.languageCode).format(vm.transactionInfo.date)), TxDetailRowDefinition( keyString: "standard_list_item_transaction_details_height_key", title: S.current.transaction_details_height, From bfeae1eca830cf88a92fef4e0a8195ea39b11505 Mon Sep 17 00:00:00 2001 From: Robert Malikowski Date: Wed, 1 Jul 2026 17:25:22 +0200 Subject: [PATCH 13/48] fix evm decimals in history --- lib/view_model/dashboard/transaction_list_item.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/view_model/dashboard/transaction_list_item.dart b/lib/view_model/dashboard/transaction_list_item.dart index 0a38ff2d2..d29fd5296 100644 --- a/lib/view_model/dashboard/transaction_list_item.dart +++ b/lib/view_model/dashboard/transaction_list_item.dart @@ -51,7 +51,7 @@ class TransactionListItem extends ActionListItem with Keyable { .withLocalSeperator(_appStore.settingsStore.languageCode); } - return transaction.amount.toStringWithSymbol(); + return transaction.amount.toStringWithSymbol(fractionalDigits: 8); } String get formattedTitle { From 1af6ab00eb4fd4c375b6888e75b9180ab4038eb2 Mon Sep 17 00:00:00 2001 From: Robert Malikowski Date: Fri, 3 Jul 2026 14:46:23 +0200 Subject: [PATCH 14/48] layout fix --- .../coins_page/assets_history/assets_history_section.dart | 1 + .../widgets/coins_page/assets_history/history_top_bar.dart | 5 ++++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/lib/new-ui/widgets/coins_page/assets_history/assets_history_section.dart b/lib/new-ui/widgets/coins_page/assets_history/assets_history_section.dart index 81fad913d..c3f699d52 100644 --- a/lib/new-ui/widgets/coins_page/assets_history/assets_history_section.dart +++ b/lib/new-ui/widgets/coins_page/assets_history/assets_history_section.dart @@ -109,6 +109,7 @@ class _AssetsHistorySectionState extends State { if (tabs.length == 1) HistoryTopBar( onTap: () => openHistoryModal(context), + roundedBottom: widget.dashboardViewModel.itemsShort.isEmpty, ), tabs[_selectedTab].content ], diff --git a/lib/new-ui/widgets/coins_page/assets_history/history_top_bar.dart b/lib/new-ui/widgets/coins_page/assets_history/history_top_bar.dart index c8749b731..cbb80111b 100644 --- a/lib/new-ui/widgets/coins_page/assets_history/history_top_bar.dart +++ b/lib/new-ui/widgets/coins_page/assets_history/history_top_bar.dart @@ -3,9 +3,10 @@ import 'package:cake_wallet/src/widgets/cake_image_widget.dart'; import 'package:flutter/material.dart'; class HistoryTopBar extends StatelessWidget { - const HistoryTopBar({super.key, required this.onTap}); + const HistoryTopBar({super.key, required this.onTap, required this.roundedBottom}); final VoidCallback onTap; + final bool roundedBottom; @override Widget build(BuildContext context) { @@ -19,10 +20,12 @@ class HistoryTopBar extends StatelessWidget { decoration: BoxDecoration( borderRadius: BorderRadius.vertical( top: Radius.circular(18), + bottom: roundedBottom ? Radius.zero : Radius.circular(18) ), color: Theme.of(context).colorScheme.surfaceContainer), child: Padding( padding: EdgeInsets.symmetric( + vertical: 8, horizontal: 12, ), child: Column( From 7b17459ed00baded5be3c42bcbe58f76c8f4772d Mon Sep 17 00:00:00 2001 From: Robert Malikowski Date: Fri, 3 Jul 2026 14:55:10 +0200 Subject: [PATCH 15/48] layout fix --- .../widgets/coins_page/assets_history/history_top_bar.dart | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/new-ui/widgets/coins_page/assets_history/history_top_bar.dart b/lib/new-ui/widgets/coins_page/assets_history/history_top_bar.dart index cbb80111b..7a3cf0651 100644 --- a/lib/new-ui/widgets/coins_page/assets_history/history_top_bar.dart +++ b/lib/new-ui/widgets/coins_page/assets_history/history_top_bar.dart @@ -20,12 +20,12 @@ class HistoryTopBar extends StatelessWidget { decoration: BoxDecoration( borderRadius: BorderRadius.vertical( top: Radius.circular(18), - bottom: roundedBottom ? Radius.zero : Radius.circular(18) + bottom: roundedBottom ? Radius.circular(18) : Radius.zero ), color: Theme.of(context).colorScheme.surfaceContainer), child: Padding( padding: EdgeInsets.symmetric( - vertical: 8, + vertical: 4, horizontal: 12, ), child: Column( @@ -43,6 +43,7 @@ class HistoryTopBar extends StatelessWidget { ) ], ), + if(!roundedBottom) Container( height: 1, width: double.infinity, From 14dcea5e1a04c643ec7d8fa617e8c95ddbffee03 Mon Sep 17 00:00:00 2001 From: Robert Malikowski Date: Fri, 3 Jul 2026 14:55:23 +0200 Subject: [PATCH 16/48] fix silent payments appearing in non-btc wallets --- .../dashboard/dashboard_view_model.dart | 207 +++++++++--------- 1 file changed, 109 insertions(+), 98 deletions(-) diff --git a/lib/view_model/dashboard/dashboard_view_model.dart b/lib/view_model/dashboard/dashboard_view_model.dart index bb34a1345..62d916fbc 100644 --- a/lib/view_model/dashboard/dashboard_view_model.dart +++ b/lib/view_model/dashboard/dashboard_view_model.dart @@ -96,104 +96,8 @@ abstract class DashboardViewModelBase with Store { isShowFirstYatIntroduction = false, isShowSecondYatIntroduction = false, isShowThirdYatIntroduction = false, - filterItems = [ - // FilterItem( - // value: () => transactionFilterStore.displayAll, - // caption: S.current.all_transactions, - // onChanged: transactionFilterStore.toggleAll), - FilterItem( - value: () => transactionFilterStore.displayOutgoing, - caption: S.current.send, - onChanged: transactionFilterStore.toggleOutgoing), - FilterItem( - value: () => transactionFilterStore.displayIncoming, - caption: S.current.receive, - onChanged: transactionFilterStore.toggleIncoming), - if (appStore.wallet!.type == WalletType.bitcoin) - FilterItem( - value: () => transactionFilterStore.displaySilentPayments, - caption: S.current.silent_payments, - onChanged: transactionFilterStore.toggleSilentPayments, - ), - SwapFilterItem( - enabledProviders: () => tradeFilterStore.enabledProviders, - allEnabled: () => tradeFilterStore.displayAllTrades, - value: () => tradeFilterStore.enabledProviders>0, - onChanged: () => - tradeFilterStore.toggleDisplayExchange(ExchangeProviderDescription.all)), - FilterItem( - value: () => orderFilterStore.displayCakePay, - caption: 'Cake Pay', - onChanged: () => - orderFilterStore.toggleDisplayOrder(OrderProviderDescription.cakePay)), - ], - exchangeFilterItems = [ - SwapProviderFilterItem( - providerDescription: ExchangeProviderDescription.changeNow, - value: () => tradeFilterStore.displayChangeNow, - onChanged: () => - tradeFilterStore.toggleDisplayExchange(ExchangeProviderDescription.changeNow)), - SwapProviderFilterItem( - providerDescription: ExchangeProviderDescription.sideShift, - value: () => tradeFilterStore.displaySideShift, - onChanged: () => - tradeFilterStore.toggleDisplayExchange(ExchangeProviderDescription.sideShift)), - SwapProviderFilterItem( - providerDescription: ExchangeProviderDescription.simpleSwap, - value: () => tradeFilterStore.displaySimpleSwap, - onChanged: () => - tradeFilterStore.toggleDisplayExchange(ExchangeProviderDescription.simpleSwap)), - SwapProviderFilterItem( - providerDescription: ExchangeProviderDescription.trocador, - value: () => tradeFilterStore.displayTrocador, - onChanged: () => - tradeFilterStore.toggleDisplayExchange(ExchangeProviderDescription.trocador)), - SwapProviderFilterItem( - providerDescription: ExchangeProviderDescription.exolix, - value: () => tradeFilterStore.displayExolix, - onChanged: () => - tradeFilterStore.toggleDisplayExchange(ExchangeProviderDescription.exolix)), - SwapProviderFilterItem( - providerDescription: ExchangeProviderDescription.chainflip, - value: () => tradeFilterStore.displayChainflip, - onChanged: () => - tradeFilterStore.toggleDisplayExchange(ExchangeProviderDescription.chainflip)), - SwapProviderFilterItem( - providerDescription: ExchangeProviderDescription.thorChain, - value: () => tradeFilterStore.displayThorChain, - onChanged: () => - tradeFilterStore.toggleDisplayExchange(ExchangeProviderDescription.thorChain)), - SwapProviderFilterItem( - providerDescription: ExchangeProviderDescription.letsExchange, - value: () => tradeFilterStore.displayLetsExchange, - onChanged: () => - tradeFilterStore.toggleDisplayExchange(ExchangeProviderDescription.letsExchange)), - SwapProviderFilterItem( - providerDescription: ExchangeProviderDescription.stealthEx, - value: () => tradeFilterStore.displayStealthEx, - onChanged: () => - tradeFilterStore.toggleDisplayExchange(ExchangeProviderDescription.stealthEx)), - SwapProviderFilterItem( - providerDescription: ExchangeProviderDescription.xoSwap, - value: () => tradeFilterStore.displayXOSwap, - onChanged: () => - tradeFilterStore.toggleDisplayExchange(ExchangeProviderDescription.xoSwap)), - SwapProviderFilterItem( - providerDescription: ExchangeProviderDescription.swapTrade, - value: () => tradeFilterStore.displaySwapTrade, - onChanged: () => - tradeFilterStore.toggleDisplayExchange(ExchangeProviderDescription.swapTrade)), - SwapProviderFilterItem( - providerDescription: ExchangeProviderDescription.swapsXyz, - value: () => tradeFilterStore.displaySwapXyz, - onChanged: () => - tradeFilterStore.toggleDisplayExchange(ExchangeProviderDescription.swapsXyz)), - SwapProviderFilterItem( - providerDescription: ExchangeProviderDescription.nearIntents, - value: () => tradeFilterStore.displayNearIntents, - onChanged: () => - tradeFilterStore.toggleDisplayExchange(ExchangeProviderDescription.nearIntents)), - ], + filterItems = [], + exchangeFilterItems = [], subname = '', name = appStore.wallet!.name, type = appStore.wallet!.type, @@ -214,6 +118,8 @@ abstract class DashboardViewModelBase with Store { unawaited(_loadConstraints()); final _wallet = wallet; + loadFilterItems(); + if (_wallet.type == WalletType.monero) { subname = monero!.getCurrentAccount(_wallet).label; @@ -346,6 +252,110 @@ abstract class DashboardViewModelBase with Store { tradeMonitor.monitorActiveTrades(wallet.id); } + + void loadFilterItems() { + filterItems = [ + // FilterItem( + // value: () => transactionFilterStore.displayAll, + // caption: S.current.all_transactions, + // onChanged: transactionFilterStore.toggleAll), + FilterItem( + value: () => transactionFilterStore.displayOutgoing, + caption: S.current.send, + onChanged: transactionFilterStore.toggleOutgoing), + FilterItem( + value: () => transactionFilterStore.displayIncoming, + caption: S.current.receive, + onChanged: transactionFilterStore.toggleIncoming), + if (appStore.wallet!.type == WalletType.bitcoin) + FilterItem( + value: () => transactionFilterStore.displaySilentPayments, + caption: S.current.silent_payments, + onChanged: transactionFilterStore.toggleSilentPayments, + ), + SwapFilterItem( + enabledProviders: () => tradeFilterStore.enabledProviders, + allEnabled: () => tradeFilterStore.displayAllTrades, + value: () => tradeFilterStore.enabledProviders>0, + onChanged: () => + tradeFilterStore.toggleDisplayExchange(ExchangeProviderDescription.all)), + FilterItem( + value: () => orderFilterStore.displayCakePay, + caption: 'Cake Pay', + onChanged: () => + orderFilterStore.toggleDisplayOrder(OrderProviderDescription.cakePay)), + ]; + exchangeFilterItems = [ + SwapProviderFilterItem( + providerDescription: ExchangeProviderDescription.changeNow, + value: () => tradeFilterStore.displayChangeNow, + onChanged: () => + tradeFilterStore.toggleDisplayExchange(ExchangeProviderDescription.changeNow)), + SwapProviderFilterItem( + providerDescription: ExchangeProviderDescription.sideShift, + value: () => tradeFilterStore.displaySideShift, + onChanged: () => + tradeFilterStore.toggleDisplayExchange(ExchangeProviderDescription.sideShift)), + SwapProviderFilterItem( + providerDescription: ExchangeProviderDescription.simpleSwap, + value: () => tradeFilterStore.displaySimpleSwap, + onChanged: () => + tradeFilterStore.toggleDisplayExchange(ExchangeProviderDescription.simpleSwap)), + SwapProviderFilterItem( + providerDescription: ExchangeProviderDescription.trocador, + value: () => tradeFilterStore.displayTrocador, + onChanged: () => + tradeFilterStore.toggleDisplayExchange(ExchangeProviderDescription.trocador)), + SwapProviderFilterItem( + providerDescription: ExchangeProviderDescription.exolix, + value: () => tradeFilterStore.displayExolix, + onChanged: () => + tradeFilterStore.toggleDisplayExchange(ExchangeProviderDescription.exolix)), + SwapProviderFilterItem( + providerDescription: ExchangeProviderDescription.chainflip, + value: () => tradeFilterStore.displayChainflip, + onChanged: () => + tradeFilterStore.toggleDisplayExchange(ExchangeProviderDescription.chainflip)), + SwapProviderFilterItem( + providerDescription: ExchangeProviderDescription.thorChain, + value: () => tradeFilterStore.displayThorChain, + onChanged: () => + tradeFilterStore.toggleDisplayExchange(ExchangeProviderDescription.thorChain)), + SwapProviderFilterItem( + providerDescription: ExchangeProviderDescription.letsExchange, + value: () => tradeFilterStore.displayLetsExchange, + onChanged: () => + tradeFilterStore.toggleDisplayExchange(ExchangeProviderDescription.letsExchange)), + SwapProviderFilterItem( + providerDescription: ExchangeProviderDescription.stealthEx, + value: () => tradeFilterStore.displayStealthEx, + onChanged: () => + tradeFilterStore.toggleDisplayExchange(ExchangeProviderDescription.stealthEx)), + SwapProviderFilterItem( + providerDescription: ExchangeProviderDescription.xoSwap, + value: () => tradeFilterStore.displayXOSwap, + onChanged: () => + tradeFilterStore.toggleDisplayExchange(ExchangeProviderDescription.xoSwap)), + SwapProviderFilterItem( + providerDescription: ExchangeProviderDescription.swapTrade, + value: () => tradeFilterStore.displaySwapTrade, + onChanged: () => + tradeFilterStore.toggleDisplayExchange(ExchangeProviderDescription.swapTrade)), + SwapProviderFilterItem( + providerDescription: ExchangeProviderDescription.swapsXyz, + value: () => tradeFilterStore.displaySwapXyz, + onChanged: () => + tradeFilterStore.toggleDisplayExchange(ExchangeProviderDescription.swapsXyz)), + SwapProviderFilterItem( + providerDescription: ExchangeProviderDescription.nearIntents, + value: () => tradeFilterStore.displayNearIntents, + onChanged: () => + tradeFilterStore.toggleDisplayExchange(ExchangeProviderDescription.nearIntents)), + ]; + } + + + bool _isTransactionDisposerCallbackRunning = false; @action @@ -1254,6 +1264,7 @@ abstract class DashboardViewModelBase with Store { this.wallet = wallet; type = wallet.type; name = wallet.name; + loadFilterItems(); if (wallet.type == WalletType.monero) { subname = monero!.getCurrentAccount(wallet).label; From 764f3d9ea568ab83fe73d20e4c39c3e148cd873f Mon Sep 17 00:00:00 2001 From: Robert Malikowski Date: Fri, 3 Jul 2026 15:44:25 +0200 Subject: [PATCH 17/48] fix history buton padding w no txs --- .../widgets/coins_page/assets_history/history_top_bar.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/new-ui/widgets/coins_page/assets_history/history_top_bar.dart b/lib/new-ui/widgets/coins_page/assets_history/history_top_bar.dart index 7a3cf0651..a46bb1ced 100644 --- a/lib/new-ui/widgets/coins_page/assets_history/history_top_bar.dart +++ b/lib/new-ui/widgets/coins_page/assets_history/history_top_bar.dart @@ -49,6 +49,7 @@ class HistoryTopBar extends StatelessWidget { width: double.infinity, color: Theme.of(context).colorScheme.outlineVariant.withAlpha(175), ) + else Container(height: 2) ], ), ), From 474312464b469fbee279ad4aae196dca030c3f52 Mon Sep 17 00:00:00 2001 From: Robert Malikowski Date: Fri, 3 Jul 2026 16:28:12 +0200 Subject: [PATCH 18/48] wrap HistoryTopBar in Observer --- .../assets_history/assets_history_section.dart | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/lib/new-ui/widgets/coins_page/assets_history/assets_history_section.dart b/lib/new-ui/widgets/coins_page/assets_history/assets_history_section.dart index c3f699d52..8d5dfce09 100644 --- a/lib/new-ui/widgets/coins_page/assets_history/assets_history_section.dart +++ b/lib/new-ui/widgets/coins_page/assets_history/assets_history_section.dart @@ -9,6 +9,7 @@ import 'package:cake_wallet/src/screens/dashboard/pages/nft_listing_page.dart'; import 'package:cake_wallet/view_model/dashboard/dashboard_view_model.dart'; import 'package:cake_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'; @@ -107,9 +108,11 @@ class _AssetsHistorySectionState extends State { selectedTab: _selectedTab, ), if (tabs.length == 1) - HistoryTopBar( - onTap: () => openHistoryModal(context), - roundedBottom: widget.dashboardViewModel.itemsShort.isEmpty, + Observer( + builder:(_)=> HistoryTopBar( + onTap: () => openHistoryModal(context), + roundedBottom: widget.dashboardViewModel.itemsShort.isEmpty, + ), ), tabs[_selectedTab].content ], From 92f1314d940feb0afc5cbe7e681ac2599cd238e3 Mon Sep 17 00:00:00 2001 From: Robert Malikowski Date: Fri, 3 Jul 2026 17:55:06 +0200 Subject: [PATCH 19/48] reset filters after closing history modal --- .../coins_page/assets_history/assets_history_section.dart | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/new-ui/widgets/coins_page/assets_history/assets_history_section.dart b/lib/new-ui/widgets/coins_page/assets_history/assets_history_section.dart index 8d5dfce09..79eb395f5 100644 --- a/lib/new-ui/widgets/coins_page/assets_history/assets_history_section.dart +++ b/lib/new-ui/widgets/coins_page/assets_history/assets_history_section.dart @@ -119,8 +119,8 @@ class _AssetsHistorySectionState extends State { ); } - void openHistoryModal(BuildContext context) { - CupertinoScaffold.showCupertinoModalBottomSheet( + Future openHistoryModal(BuildContext context) async { + await CupertinoScaffold.showCupertinoModalBottomSheet( context: context, builder: (context) => ModalNavigator( rootPage: Material( @@ -128,5 +128,6 @@ class _AssetsHistorySectionState extends State { child: HistoryModal(dashboardViewModel: widget.dashboardViewModel)), parentContext: context, )); + widget.dashboardViewModel.changeAllFilterItems(true); } } From 00b7d7ce0d59e43d6f887cbb5e11217a23e130e8 Mon Sep 17 00:00:00 2001 From: Omar Date: Sun, 5 Jul 2026 02:34:13 +0300 Subject: [PATCH 20/48] minor fixes --- cw_bitcoin/lib/lightning/lightning_wallet.dart | 4 +++- cw_core/lib/parse_fixed.dart | 7 +++++++ res/values/strings_it.arb | 2 +- 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/cw_bitcoin/lib/lightning/lightning_wallet.dart b/cw_bitcoin/lib/lightning/lightning_wallet.dart index d78c88d13..d93a84b58 100644 --- a/cw_bitcoin/lib/lightning/lightning_wallet.dart +++ b/cw_bitcoin/lib/lightning/lightning_wallet.dart @@ -123,7 +123,9 @@ class LightningWallet { Future close() async { _eventSubscription?.cancel(); - await _sdk?.disconnect(); + try { + await _sdk?.disconnect(); + } catch (_) {} _logSubscription?.cancel(); } diff --git a/cw_core/lib/parse_fixed.dart b/cw_core/lib/parse_fixed.dart index fede4df09..51a6a7a58 100644 --- a/cw_core/lib/parse_fixed.dart +++ b/cw_core/lib/parse_fixed.dart @@ -29,6 +29,13 @@ BigInt? tryParseFixed(String value, int decimals) { BigInt parseFixed(String value, int decimals) { final multiplier = getMultiplier(decimals); + /// handle weird cases where users enter spaces and currency after the amount + /// This should be handled from UI field to prevent non numerical values + /// but will be in the refactoring + if (value.contains(" ")) { + value = value.split(" ").first; + } + final negative = value.startsWith("-"); if (negative) value = value.substring(1); diff --git a/res/values/strings_it.arb b/res/values/strings_it.arb index 03da71fc5..2bd228544 100644 --- a/res/values/strings_it.arb +++ b/res/values/strings_it.arb @@ -166,7 +166,7 @@ "centralized": "Centralizzato", "chain_id": "ID catena", "chains": "Chain", - "change": "Resto", + "change": "Cambia", "change_backup_password_alert": "I tuoi file di backup precedenti non potranno essere importati con la nuova password di backup. La nuova password di backup verrà usata solo per i nuovi file di backup. Sei sicuro di voler cambiare la password di backup?", "change_currency": "Cambia valuta", "change_current_node": "Sei sicuro di voler cambiare il nodo corrente in ${node}?", From 839530063172bede108c61bbaaccd3539febc2ae Mon Sep 17 00:00:00 2001 From: Omar Hatem Date: Sun, 5 Jul 2026 17:10:26 +0300 Subject: [PATCH 21/48] remove old providers [skip ci] --- lib/store/dashboard/trade_filter_store.dart | 2 -- 1 file changed, 2 deletions(-) diff --git a/lib/store/dashboard/trade_filter_store.dart b/lib/store/dashboard/trade_filter_store.dart index abc4c337a..2fee9cc70 100644 --- a/lib/store/dashboard/trade_filter_store.dart +++ b/lib/store/dashboard/trade_filter_store.dart @@ -71,10 +71,8 @@ abstract class TradeFilterStoreBase with Store { @computed int get enabledProviders => [ - displayXMRTO, displayChangeNow, displaySideShift, - displayMorphToken, displaySimpleSwap, displayTrocador, displayExolix, From 6cb31493223039a79be9e723b70518c0fe4405a1 Mon Sep 17 00:00:00 2001 From: Konstantin Ullrich Date: Mon, 6 Jul 2026 14:42:03 +0200 Subject: [PATCH 22/48] feat: add lightning preimage to transaction details --- .../lib/lightning/lightning_wallet.dart | 10 ++++++++- .../transaction_details_view_model.dart | 21 +++++++++++-------- res/values/strings_en.arb | 3 ++- 3 files changed, 23 insertions(+), 11 deletions(-) diff --git a/cw_bitcoin/lib/lightning/lightning_wallet.dart b/cw_bitcoin/lib/lightning/lightning_wallet.dart index d93a84b58..42dda7cb2 100644 --- a/cw_bitcoin/lib/lightning/lightning_wallet.dart +++ b/cw_bitcoin/lib/lightning/lightning_wallet.dart @@ -441,6 +441,11 @@ class LightningWallet { direction = TransactionDirection.incoming; } + String? preimage; + if (payment.details != null && payment.details is PaymentDetails_Lightning) { + preimage = (payment.details as PaymentDetails_Lightning).htlcDetails.preimage; + } + return ElectrumTransactionInfo( WalletType.bitcoin, id: payment.id, @@ -450,7 +455,10 @@ class LightningWallet { fee: Money(payment.fees, currency), date: DateTime.fromMillisecondsSinceEpoch(payment.timestamp.toInt() * 1000), confirmations: payment.status == PaymentStatus.pending ? 0 : 10, - additionalInfo: {"isLightning": true}, + additionalInfo: { + "isLightning": true, + if (preimage != null) "preimage": preimage, + }, ); } diff --git a/lib/view_model/transaction_details_view_model.dart b/lib/view_model/transaction_details_view_model.dart index 7199ed1a9..0cc1bdb82 100644 --- a/lib/view_model/transaction_details_view_model.dart +++ b/lib/view_model/transaction_details_view_model.dart @@ -9,7 +9,6 @@ import 'package:cake_wallet/zano/zano.dart'; import 'package:cw_core/crypto_amount_format.dart'; import 'package:cw_core/crypto_currency.dart'; import 'package:cw_core/currency_for_wallet_type.dart'; -import 'package:cw_core/utils/print_verbose.dart'; import 'package:cw_core/wallet_base.dart'; import 'package:cw_core/transaction_info.dart'; import 'package:cw_core/wallet_type.dart'; @@ -50,10 +49,9 @@ String _moneroRecipientAddressForDisplay(String raw, WalletType walletType) { return match?.group(0) ?? raw.trim(); } -bool isLightning(TransactionInfo tx) { - printV(tx.additionalInfo); - return (tx.additionalInfo["isLightning"] as bool?) ?? false; -} +bool isLightning(TransactionInfo tx) => (tx.additionalInfo["isLightning"] as bool?) ?? false; + +bool hasLightningPreimage(TransactionInfo tx) => (tx.additionalInfo["preimage"] as String?) != null; class TxDetailRowDefinition { final String keyString; @@ -188,6 +186,13 @@ class TxDetailRowDefinition { }, applicable: (vm) => vm.wallet.type == WalletType.monero), + TxDetailRowDefinition( + keyString: "standard_list_item_lightning_preimage", + title: S.current.transaction_preimage, + valueGetter: (vm) => vm.transactionInfo.additionalInfo['preimage'] as String? ?? "", + applicable: (vm) => + hasLightningPreimage(vm.transactionInfo) && isLightning(vm.transactionInfo), + ), TxDetailRowDefinition( keyString: "standard_list_item_transaction_confirmed_key", @@ -316,10 +321,9 @@ abstract class TransactionDetailsViewModelBase with Store { TransactionPriority? transactionPriority; CryptoCurrency get transactionAsset { + if (isEVMCompatibleChain(wallet.type)) return evm!.assetOfTransaction(wallet, transactionInfo); - if (isEVMCompatibleChain(wallet.type)) { - return evm!.assetOfTransaction(wallet, transactionInfo); - } + if (isLightning(transactionInfo)) return CryptoCurrency.btcln; return switch (wallet.type) { WalletType.solana => solana!.assetOfTransaction(wallet, transactionInfo), @@ -329,7 +333,6 @@ abstract class TransactionDetailsViewModelBase with Store { }; } - // TODO integrate these getters with the TransactionInfo object String get formattedPendingStatus { switch (wallet.type) { diff --git a/res/values/strings_en.arb b/res/values/strings_en.arb index 1debd0a8f..1523c53e2 100644 --- a/res/values/strings_en.arb +++ b/res/values/strings_en.arb @@ -1253,6 +1253,7 @@ "transaction_error": "Transaction Error", "transaction_key": "Transaction Key", "transaction_memo_optional": "Transaction Memo (optional)", + "transaction_preimage": "Preimage", "transaction_priority_fast": "Fast", "transaction_priority_fastest": "Fastest", "transaction_priority_medium": "Medium", @@ -1452,4 +1453,4 @@ "zcash_card_missing_funds": "Missing funds?", "zcash_card_scan": "Scan", "zcash_card_warning": "Do not close the app until the procedure completes, if you do so this process will need to restart from scratch." -} \ No newline at end of file +} From 7928c912e81d88df803e7ec5c3c1065b252ec9f6 Mon Sep 17 00:00:00 2001 From: Serhii <17529954+serhii-bor@users.noreply.github.com> Date: Mon, 6 Jul 2026 16:48:30 +0300 Subject: [PATCH 23/48] Address resolver refactoring (#3291) * restructure address resolver sources * refactor address resolver into lookup service * refactor address resolver to provider pattern * use addressSource to drive providers & settings * error handling * add address source icons * Update lib/core/address_validator.dart [skip ci] Co-authored-by: Omar Hatem * review fixes * add validations to extractParsedAddress * skip address resolver for valid addresses * minor fix [skip ci] * fix formattedName x[skip ci] Co-authored-by: malik1004x * fix formatted name zano [skip ci] Co-authored-by: malik1004x * address PR review comments * add icons for new address source resolvers * confirm parsed addresses in contacts * minor fixes * LNUrl for BTCLN only and contact name handling --------- Co-authored-by: Omar Hatem Co-authored-by: malik1004x --- .../src/cards/cake_pay_buy_card_page.dart | 16 +- .../address_lookup_provider.dart | 22 + .../address_resolver_service.dart | 143 +++++ .../address_resolver_utils.dart | 104 ++++ .../address_resolver/address_sources.dart | 87 +++ .../bip_353/bip_353_address_provider.dart | 68 +++ .../bip_353}/bip_353_record.dart | 71 +-- .../ens/ens_address_provider.dart | 49 ++ lib/core/address_resolver/ens/ens_record.dart | 55 ++ .../fio/fio_address_provider.dart | 125 ++++ .../lnurl_pay/lnurl_pay_address_provider.dart | 66 +++ .../lnurl_pay}/lnurlpay_record.dart | 0 .../mastodon/mastodon_address_provider.dart | 93 +++ .../mastodon/mastodon_api.dart | 2 +- .../mastodon/mastodon_user.dart | 0 .../nostr/nostr_address_provider.dart | 66 +++ .../address_resolver/nostr/nostr_api.dart | 127 ++++ .../address_resolver}/nostr/nostr_user.dart | 0 .../openalias/openalias_address_provider.dart | 64 ++ .../openalias/openalias_record.dart | 69 +++ lib/core/address_resolver/parsed_address.dart | 22 + .../thorchain/thorchain_address_provider.dart | 65 +++ .../twitter/twitter_address_provider.dart | 97 ++++ .../twitter/twitter_api.dart | 2 +- .../twitter/twitter_user.dart | 0 .../unstoppable_address_provider.dart | 235 ++++++++ .../wellknown/wellknown_address_provider.dart | 49 ++ .../wellknown/wellknown_record.dart | 78 +++ .../yat/yat_address_provider.dart | 59 ++ lib/core/address_resolver/yat/yat_record.dart | 19 + .../address_resolver/yat/yat_service.dart | 67 +++ .../address_resolver}/yat/yat_store.dart | 0 .../zano/zano_alias_address_provider.dart | 75 +++ .../zcash/zcash_address_provider.dart | 49 ++ .../zcash/zcash_me_address_provider.dart | 74 +++ .../zcash}/zcash_names_record.dart | 0 lib/core/address_validator.dart | 87 ++- lib/core/yat_service.dart | 55 -- lib/di.dart | 27 +- lib/entities/ens_record.dart | 54 -- lib/entities/fio_address_provider.dart | 65 --- lib/entities/openalias_record.dart | 89 --- lib/entities/parse_address_from_domain.dart | 549 ------------------ lib/entities/parsed_address.dart | 212 ------- lib/entities/preferences_key.dart | 6 + lib/entities/unstoppable_domain_address.dart | 30 - lib/entities/wellknown_record.dart | 93 --- lib/entities/yat_record.dart | 13 - lib/entities/zano_alias.dart | 29 - lib/new-ui/pages/send_page.dart | 59 +- lib/new-ui/pages/swap_page.dart | 13 +- .../lightning_username_bloc.dart | 2 +- .../widgets/send_page/send_confirm_sheet.dart | 15 +- lib/nostr/nostr_api.dart | 150 ----- lib/src/screens/buy/buy_sell_page.dart | 42 +- .../screens/contact/contact_list_page.dart | 1 + lib/src/screens/contact/contact_page.dart | 52 +- lib/src/screens/exchange/exchange_page.dart | 10 +- lib/src/screens/send/send_page.dart | 1 - .../widgets/extract_address_from_parsed.dart | 150 ----- lib/src/screens/send/widgets/send_card.dart | 5 - .../send/widgets/send_template_card.dart | 2 - .../screens/settings/domain_lookups_page.dart | 57 +- .../screens/wallet_list/filtered_list.dart | 3 + lib/src/screens/yat/yat_popup.dart | 2 +- lib/src/widgets/base_alert_dialog.dart | 6 +- .../confirm_sending_bottom_sheet_widget.dart | 2 +- lib/store/settings_store.dart | 69 ++- lib/store/yat/yat_exception.dart | 10 - .../contact_list/contact_view_model.dart | 61 +- .../dashboard/dashboard_view_model.dart | 2 +- .../restore/wallet_restore_from_qr_code.dart | 4 +- lib/view_model/send/output.dart | 65 ++- lib/view_model/send/send_view_model.dart | 50 +- .../settings/connection_sync_view_model.dart | 139 ++++- .../wallet_address_list_view_model.dart | 2 +- pubspec_base.yaml | 1 + res/pictures/address_sources/bip353.svg | 4 + res/pictures/address_sources/ens.svg | 14 + res/pictures/address_sources/fio.svg | 20 + .../address_sources/icon-lnurl-pay.svg | 4 + .../address_sources/icon-thorchain.svg | 16 + .../address_sources/icon-zcash-me.svg | 230 ++++++++ .../address_sources/icon-zcashnames.svg | 52 ++ res/pictures/address_sources/mastodon.svg | 11 + res/pictures/address_sources/nostr.svg | 4 + res/pictures/address_sources/openalias.svg | 13 + res/pictures/address_sources/unstoppable.svg | 5 + res/pictures/address_sources/wellknown.svg | 4 + res/pictures/address_sources/x.svg | 11 + res/pictures/address_sources/yat.svg | 11 + res/pictures/address_sources/zano.svg | 37 ++ .../parse_address_from_domain_test.dart | 2 +- 93 files changed, 3044 insertions(+), 1796 deletions(-) create mode 100644 lib/core/address_resolver/address_lookup_provider.dart create mode 100644 lib/core/address_resolver/address_resolver_service.dart create mode 100644 lib/core/address_resolver/address_resolver_utils.dart create mode 100644 lib/core/address_resolver/address_sources.dart create mode 100644 lib/core/address_resolver/bip_353/bip_353_address_provider.dart rename lib/{entities => core/address_resolver/bip_353}/bip_353_record.dart (59%) create mode 100644 lib/core/address_resolver/ens/ens_address_provider.dart create mode 100644 lib/core/address_resolver/ens/ens_record.dart create mode 100644 lib/core/address_resolver/fio/fio_address_provider.dart create mode 100644 lib/core/address_resolver/lnurl_pay/lnurl_pay_address_provider.dart rename lib/{entities => core/address_resolver/lnurl_pay}/lnurlpay_record.dart (100%) create mode 100644 lib/core/address_resolver/mastodon/mastodon_address_provider.dart rename lib/{ => core/address_resolver}/mastodon/mastodon_api.dart (95%) rename lib/{ => core/address_resolver}/mastodon/mastodon_user.dart (100%) create mode 100644 lib/core/address_resolver/nostr/nostr_address_provider.dart create mode 100644 lib/core/address_resolver/nostr/nostr_api.dart rename lib/{ => core/address_resolver}/nostr/nostr_user.dart (100%) create mode 100644 lib/core/address_resolver/openalias/openalias_address_provider.dart create mode 100644 lib/core/address_resolver/openalias/openalias_record.dart create mode 100644 lib/core/address_resolver/parsed_address.dart create mode 100644 lib/core/address_resolver/thorchain/thorchain_address_provider.dart create mode 100644 lib/core/address_resolver/twitter/twitter_address_provider.dart rename lib/{ => core/address_resolver}/twitter/twitter_api.dart (96%) rename lib/{ => core/address_resolver}/twitter/twitter_user.dart (100%) create mode 100644 lib/core/address_resolver/unstoppable/unstoppable_address_provider.dart create mode 100644 lib/core/address_resolver/wellknown/wellknown_address_provider.dart create mode 100644 lib/core/address_resolver/wellknown/wellknown_record.dart create mode 100644 lib/core/address_resolver/yat/yat_address_provider.dart create mode 100644 lib/core/address_resolver/yat/yat_record.dart create mode 100644 lib/core/address_resolver/yat/yat_service.dart rename lib/{store => core/address_resolver}/yat/yat_store.dart (100%) create mode 100644 lib/core/address_resolver/zano/zano_alias_address_provider.dart create mode 100644 lib/core/address_resolver/zcash/zcash_address_provider.dart create mode 100644 lib/core/address_resolver/zcash/zcash_me_address_provider.dart rename lib/{entities => core/address_resolver/zcash}/zcash_names_record.dart (100%) delete mode 100644 lib/core/yat_service.dart delete mode 100644 lib/entities/ens_record.dart delete mode 100644 lib/entities/fio_address_provider.dart delete mode 100644 lib/entities/openalias_record.dart delete mode 100644 lib/entities/parse_address_from_domain.dart delete mode 100644 lib/entities/parsed_address.dart delete mode 100644 lib/entities/unstoppable_domain_address.dart delete mode 100644 lib/entities/wellknown_record.dart delete mode 100644 lib/entities/yat_record.dart delete mode 100644 lib/entities/zano_alias.dart delete mode 100644 lib/nostr/nostr_api.dart delete mode 100644 lib/src/screens/send/widgets/extract_address_from_parsed.dart delete mode 100644 lib/store/yat/yat_exception.dart create mode 100644 res/pictures/address_sources/bip353.svg create mode 100644 res/pictures/address_sources/ens.svg create mode 100644 res/pictures/address_sources/fio.svg create mode 100644 res/pictures/address_sources/icon-lnurl-pay.svg create mode 100644 res/pictures/address_sources/icon-thorchain.svg create mode 100644 res/pictures/address_sources/icon-zcash-me.svg create mode 100644 res/pictures/address_sources/icon-zcashnames.svg create mode 100644 res/pictures/address_sources/mastodon.svg create mode 100644 res/pictures/address_sources/nostr.svg create mode 100644 res/pictures/address_sources/openalias.svg create mode 100644 res/pictures/address_sources/unstoppable.svg create mode 100644 res/pictures/address_sources/wellknown.svg create mode 100644 res/pictures/address_sources/x.svg create mode 100644 res/pictures/address_sources/yat.svg create mode 100644 res/pictures/address_sources/zano.svg diff --git a/lib/cake_pay/src/cards/cake_pay_buy_card_page.dart b/lib/cake_pay/src/cards/cake_pay_buy_card_page.dart index 5442dcff1..8380897b9 100644 --- a/lib/cake_pay/src/cards/cake_pay_buy_card_page.dart +++ b/lib/cake_pay/src/cards/cake_pay_buy_card_page.dart @@ -11,8 +11,8 @@ import 'package:cake_wallet/cake_pay/src/widgets/link_extractor.dart'; import 'package:cake_wallet/cake_pay/src/widgets/rounded_overlay_cards_widget.dart'; import 'package:cake_wallet/cake_pay/src/widgets/text_icon_button.dart'; import 'package:cake_wallet/cake_pay/src/widgets/three_checkbox_alert_content_widget.dart'; +import 'package:cake_wallet/core/address_resolver/parsed_address.dart'; import 'package:cake_wallet/core/execution_state.dart'; -import 'package:cake_wallet/entities/parsed_address.dart'; import 'package:cake_wallet/generated/i18n.dart'; import 'package:cake_wallet/new-ui/widgets/keyboard_hide_overlay.dart'; import 'package:cake_wallet/routes.dart'; @@ -584,8 +584,11 @@ class CakePayBuyCardPage extends BasePage { final displayingOutputs = _sendViewModel.outputs .map((o) => o.OutputCopyWithParsedAddress( parsedAddress: ParsedAddress( - addresses: [o.address], - name: 'Cake Pay', + parsedAddressByCurrencyMap: { + cakePayBuyCardViewModel.sendViewModel.selectedCryptoCurrency: + o.address, + }, + handle: 'Cake Pay', profileName: order?.cards.first.cardName ?? 'Cake Pay', profileImageUrl: order?.cards.first.cardImagePath ?? '', ), @@ -653,8 +656,11 @@ class CakePayBuyCardPage extends BasePage { final displayingOutputs = outputsCopy .map((o) => o.OutputCopyWithParsedAddress( parsedAddress: ParsedAddress( - addresses: [o.address], - name: 'Cake Pay', + parsedAddressByCurrencyMap: { + cakePayBuyCardViewModel.sendViewModel.selectedCryptoCurrency: + o.address, + }, + handle: 'Cake Pay', profileName: order?.cards.first.cardName ?? 'Cake Pay', profileImageUrl: order?.cards.first.cardImagePath ?? '', ), diff --git a/lib/core/address_resolver/address_lookup_provider.dart b/lib/core/address_resolver/address_lookup_provider.dart new file mode 100644 index 000000000..9feff5610 --- /dev/null +++ b/lib/core/address_resolver/address_lookup_provider.dart @@ -0,0 +1,22 @@ +import 'package:cake_wallet/core/address_resolver/address_sources.dart'; +import 'package:cake_wallet/core/address_resolver/parsed_address.dart'; +import 'package:cake_wallet/store/settings_store.dart'; +import 'package:cw_core/crypto_currency.dart'; +import 'package:cw_core/wallet_base.dart'; + +abstract class AddressLookupProvider { + + AddressSource get source; + + List get supportedCurrencies; + + bool isEnabled(SettingsStore settingsStore); + + bool canHandle(String query); + + Future> resolve({ + required String query, + required List currencies, + required WalletBase wallet, + }); +} diff --git a/lib/core/address_resolver/address_resolver_service.dart b/lib/core/address_resolver/address_resolver_service.dart new file mode 100644 index 000000000..8592c53c6 --- /dev/null +++ b/lib/core/address_resolver/address_resolver_service.dart @@ -0,0 +1,143 @@ +import 'package:cake_wallet/core/address_resolver/address_lookup_provider.dart'; +import 'package:cake_wallet/core/address_resolver/address_sources.dart'; +import 'package:cake_wallet/core/address_resolver/bip_353/bip_353_address_provider.dart'; +import 'package:cake_wallet/core/address_resolver/ens/ens_address_provider.dart'; +import 'package:cake_wallet/core/address_resolver/fio/fio_address_provider.dart'; +import 'package:cake_wallet/core/address_resolver/lnurl_pay/lnurl_pay_address_provider.dart'; +import 'package:cake_wallet/core/address_resolver/mastodon/mastodon_address_provider.dart'; +import 'package:cake_wallet/core/address_resolver/nostr/nostr_address_provider.dart'; +import 'package:cake_wallet/core/address_resolver/openalias/openalias_address_provider.dart'; +import 'package:cake_wallet/core/address_resolver/parsed_address.dart'; +import 'package:cake_wallet/core/address_resolver/thorchain/thorchain_address_provider.dart'; +import 'package:cake_wallet/core/address_resolver/twitter/twitter_address_provider.dart'; +import 'package:cake_wallet/core/address_resolver/unstoppable/unstoppable_address_provider.dart'; +import 'package:cake_wallet/core/address_resolver/wellknown/wellknown_address_provider.dart'; +import 'package:cake_wallet/core/address_resolver/yat/yat_address_provider.dart'; +import 'package:cake_wallet/core/address_resolver/zano/zano_alias_address_provider.dart'; +import 'package:cake_wallet/core/address_resolver/zcash/zcash_address_provider.dart'; +import 'package:cake_wallet/core/address_resolver/zcash/zcash_me_address_provider.dart'; +import 'package:cake_wallet/store/settings_store.dart'; +import 'package:cw_core/crypto_currency.dart'; +import 'package:cw_core/utils/print_verbose.dart'; +import 'package:cw_core/wallet_base.dart'; + +class AddressResolverService { + AddressResolverService({required this.settingsStore}); + + final SettingsStore settingsStore; + +// Sources available for address resolution and lookup settings. + static const supportedSources = [ + AddressSource.lnurlPay, + AddressSource.twitter, + AddressSource.mastodon, + AddressSource.yatRecord, + AddressSource.unstoppableDomains, + AddressSource.openAlias, + AddressSource.ens, + AddressSource.zcashName, + AddressSource.zcashAddress, + AddressSource.wellKnown, + AddressSource.zanoAlias, + AddressSource.bip353, + AddressSource.fio, + AddressSource.thorChain, + AddressSource.nostr, + ]; + + List _buildProviders() { + final allProviders = [ + LNUrlPayAddressProvider(), + TwitterAddressProvider(), + MastodonAddressProvider(), + YatAddressProvider(), + UnstoppableAddressProvider(), + OpenaliasAddressProvider(), + EnsAddressProvider(), + ZcashNameAddressProvider(), + ZcashMeAddressProvider(), + WellKnownAddressProvider(), + ZanoAliasAddressProvider(), + Bip353AddressProvider(), + FioAddressProvider(), + ThorchainAddressProvider(), + NostrAddressProvider(), + ]; + + return allProviders.where((provider) => supportedSources.contains(provider.source)).toList(); + } + + Future> resolve({ + required String query, + required WalletBase wallet, + required CryptoCurrency currency, + }) async { + try { + final providers = _buildProviders(); + // return first entry if currency is specified and provider supports it + if (currency != null) { + for (final provider in providers) { + final cur = currency.title; + final src = provider.source.label; + + printV('[address resolver service] checking $src - $cur - $query'); + if (!provider.isEnabled(settingsStore)) { + printV('[address resolver service] ...skipping $src (disabled)'); + continue; + } + if (!provider.canHandle(query)) { + printV('[address resolver service] ...skipping $src (cannot handle)'); + continue; + } + if (!provider.supportedCurrencies.contains(currency)) { + printV('[address resolver service] ...skipping $src (unsupported currency)'); + continue; + } + + try { + printV('[address resolver service] ...resolving $src - $cur - $query'); + final results = await provider.resolve( + query: query, + currencies: [currency], + wallet: wallet, + ); + + if (results.isNotEmpty) { + final parsedAddress = results.first.parsedAddressByCurrencyMap[currency]; + final parsedAddressStr = parsedAddress == null || parsedAddress.isEmpty ? 'N/A' : parsedAddress; + printV('[address resolver service] resolved $src - $cur - $parsedAddressStr'); + return [results.first]; + } + } catch (e) { + printV('[address resolver service] Error resolving $src: $e'); + } + } + + return []; + } + + return []; // TODO: Implement multi-currency resolution if no specific currency is provided. + + final tasks = >>[]; + + for (final provider in providers) { + if (!provider.isEnabled(settingsStore)) continue; + if (!provider.canHandle(query)) continue; + + tasks.add( + provider.resolve( + query: query, + currencies: provider.supportedCurrencies, + wallet: wallet, + ), + ); + } + + final results = await Future.wait(tasks); + return results.expand((items) => items).toList(); + } catch (e) { + printV('Error resolving address: $e'); + return []; + } + } +} diff --git a/lib/core/address_resolver/address_resolver_utils.dart b/lib/core/address_resolver/address_resolver_utils.dart new file mode 100644 index 000000000..f9f59240c --- /dev/null +++ b/lib/core/address_resolver/address_resolver_utils.dart @@ -0,0 +1,104 @@ +import 'package:cake_wallet/core/address_resolver/unstoppable/unstoppable_address_provider.dart'; +import 'package:cake_wallet/core/address_validator.dart'; +import 'package:cw_core/crypto_currency.dart'; +import 'package:cw_core/utils/print_verbose.dart'; + +class AddressResolverUtils { + static Future> resolveUnstoppableDomainFromText({ + required String raw, + required List currencies, + }) async { + final domain = extractUnstoppableDomain(raw); + if (domain.isEmpty) return {}; + + final result = {}; + + for (final currency in currencies) { + final address = await UnstoppableAddressProvider.fetchUnstoppableDomainAddress(domain, currency.title); + if (address.isNotEmpty) { + result[currency] = address; + } + } + + return result; + } + + static String extractUnstoppableDomain(String raw) { +// sort by length to avoid matching shorter tld instead of longer + final domains = List.from(UnstoppableAddressProvider.unstoppableDomains) + ..sort((a, b) => b.length.compareTo(a.length)); + for (final tld in domains) { + final pattern = RegExp(r'([A-Za-z0-9-]+)\.' + RegExp.escape(tld), caseSensitive: false); + final match = pattern.firstMatch(raw); + if (match != null) return match.group(0)!; + } + return ''; + } + + static Map extractAddressesFromText({ + required String raw, + required List currencies, + bool requireSurroundingWhitespaces = true, + }) { + final result = {}; + var text = raw; + + for (final currency in currencies) { + final address = extractAddressByType( + raw: text, + type: currency, + requireSurroundingWhitespaces: requireSurroundingWhitespaces, + ); + + if (address != null && address.isNotEmpty) { + result[currency] = address; + text = text.replaceFirst(address, ''); + } + } + + return result; + } + + static String? extractAddressByType({ + required String raw, + required CryptoCurrency type, + bool requireSurroundingWhitespaces = true, + }) { + var addressPattern = AddressValidator.getAddressFromStringPattern(type); + if (addressPattern == null) { + printV('Unknown pattern for $type'); + return null; + } + if (requireSurroundingWhitespaces) addressPattern = "$BEFORE_REGEX$addressPattern$AFTER_REGEX"; + final text = stripHtmlTags(raw); + final match = RegExp(addressPattern, multiLine: true, caseSensitive: false).firstMatch(text); + if (match == null) return null; + return match.group(0)?.replaceAllMapped(RegExp('[^0-9a-zA-Z]|bitcoincash:|nano_|ban_'), + (Match match) { + String group = match.group(0)!; + if (group.startsWith('bitcoincash:') || + group.startsWith('nano_') || + group.startsWith('ban_')) { + return group; + } + return ''; + }); + } + + static bool isEmailFormat(String address) { + final RegExp emailRegex = RegExp( + r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$', + caseSensitive: false, + ); + return emailRegex.hasMatch(address); + } + + static String stripHtmlTags(String value) { + return value + .replaceAll(RegExp(r'[\u2028\u2029]'), '\n') + .replaceAll(RegExp(r'', caseSensitive: false), '\n') + .replaceAll(RegExp(r'', caseSensitive: false), '\n') + .replaceAll(RegExp(r'<[^>]*>'), ' ') + .trim(); + } +} diff --git a/lib/core/address_resolver/address_sources.dart b/lib/core/address_resolver/address_sources.dart new file mode 100644 index 000000000..729ef478a --- /dev/null +++ b/lib/core/address_resolver/address_sources.dart @@ -0,0 +1,87 @@ +import 'package:cake_wallet/core/address_validator.dart'; +import 'package:cw_core/crypto_currency.dart'; + +enum AddressSource { + twitter(label: 'X', iconPath: 'assets/new-ui/address_sources/x.svg', alias: '@username'), + unstoppableDomains( + label: 'Unstoppable Domains', + iconPath: 'assets/new-ui/address_sources/unstoppable.svg', + alias: 'domain.tld'), + openAlias( + label: 'OpenAlias', + iconPath: 'assets/new-ui/address_sources/openalias.svg', + alias: 'name.domain.tld'), + yatRecord(label: 'Yat', iconPath: 'assets/new-ui/address_sources/yat.svg', alias: '🎂🎂🎂'), + fio(label: 'FIO', iconPath: 'assets/new-ui/address_sources/fio.svg', alias: 'user@domain'), + ens( + label: 'Ethereum Name Service', + iconPath: 'assets/new-ui/address_sources/ens.svg', + alias: 'domain.eth'), + mastodon( + label: 'Mastodon', + iconPath: 'assets/new-ui/address_sources/mastodon.svg', + alias: '@username@domain.tld'), + nostr( + label: 'Nostr', + iconPath: 'assets/new-ui/address_sources/nostr.svg', + alias: 'user@domain.tld'), + thorChain( + label: 'ThorChain', + iconPath: 'assets/new-ui/address_sources/icon-thorchain.svg', + alias: 'name'), + wellKnown( + label: '.wellknown', + iconPath: 'assets/new-ui/address_sources/wellknown.svg', + alias: 'domain.tld'), + zanoAlias( + label: 'Zano Alias', iconPath: 'assets/new-ui/address_sources/zano.svg', alias: '@alias'), + bip353( + label: 'BIP353', + iconPath: 'assets/new-ui/address_sources/bip353.svg', + alias: 'user@domain.com'), + zcashAddress( + label: 'Zcash.me', + iconPath: 'assets/new-ui/address_sources/icon-zcash-me.svg', + alias: 'zcash.me/username'), + zcashName( + label: 'Zcash Names', + iconPath: 'assets/new-ui/address_sources/icon-zcashnames.svg', + alias: 'name.zec'), + lnurlPay( + label: 'LNURL Pay', + iconPath: 'assets/new-ui/address_sources/icon-lnurl-pay.svg', + alias: 'user@domain.com'), + contact(label: 'Contact', iconPath: ''), + notParsed(label: 'Unknown', iconPath: ''), + ; + + const AddressSource({ + required this.label, + required this.iconPath, + this.alias = '', + }); + + final String label; + final String iconPath; + final String alias; +} + +extension AddressSourceIndex on AddressSource { + int get raw => index; + + static AddressSource fromRaw(int raw) => + AddressSource.values[raw.clamp(0, AddressSource.values.length - 1)]; +} + +extension AddressSourceNameParser on AddressSource { + static AddressSource fromLabel(String? text) { + if (text == null || text.trim().isEmpty) { + return AddressSource.notParsed; + } + final needle = text.trim().toLowerCase(); + return AddressSource.values.firstWhere( + (src) => src.label.toLowerCase() == needle, + orElse: () => AddressSource.notParsed, + ); + } +} diff --git a/lib/core/address_resolver/bip_353/bip_353_address_provider.dart b/lib/core/address_resolver/bip_353/bip_353_address_provider.dart new file mode 100644 index 000000000..3a46f87a4 --- /dev/null +++ b/lib/core/address_resolver/bip_353/bip_353_address_provider.dart @@ -0,0 +1,68 @@ +import 'package:cake_wallet/core/address_resolver/address_lookup_provider.dart'; +import 'package:cake_wallet/core/address_resolver/address_sources.dart'; +import 'package:cake_wallet/core/address_resolver/bip_353/bip_353_record.dart'; +import 'package:cake_wallet/core/address_resolver/parsed_address.dart'; +import 'package:cake_wallet/store/settings_store.dart'; +import 'package:cw_core/crypto_currency.dart'; +import 'package:cw_core/utils/print_verbose.dart'; +import 'package:cw_core/wallet_base.dart'; + +class Bip353AddressProvider extends AddressLookupProvider { + @override + AddressSource get source => AddressSource.bip353; + + @override + List get supportedCurrencies => [CryptoCurrency.btc]; + + @override + bool isEnabled(SettingsStore settingsStore) => settingsStore.lookupsBip353; + + @override + bool canHandle(String query) { + final value = query.trim(); + return value.contains('@') && value.contains('.') && !value.startsWith('@'); + } + + @override + Future> resolve({ + required String query, + required List currencies, + required WalletBase wallet, + }) async { + try { + final result = {}; + String? dnsProof; + + for (final cur in currencies) { + final bip353AddressMap = await Bip353Record.fetchUriByCryptoCurrency(query, cur.title); + if (bip353AddressMap == null || bip353AddressMap.isEmpty) continue; + + final spAddress = bip353AddressMap['sp']; + final address = bip353AddressMap['address']; + final chosenAddress = spAddress?.isNotEmpty == true ? spAddress : address; + + if (chosenAddress != null && chosenAddress.isNotEmpty) { + result[cur] = chosenAddress; + } + } + + if (result.isEmpty) return []; + + try { + dnsProof = await Bip353Record.fetchDnsProof(query); + } catch (e) { + printV('Bip353Record.fetchDnsProof error: $e'); + } + + return [ParsedAddress( + parsedAddressByCurrencyMap: result, + addressSource: AddressSource.bip353, + handle: query, + bip353DnsProof: dnsProof ?? '', + )]; + } catch (e) { + printV('[address resolver] Error resolving BIP353 address: $e'); + return []; + } + } +} \ No newline at end of file diff --git a/lib/entities/bip_353_record.dart b/lib/core/address_resolver/bip_353/bip_353_record.dart similarity index 59% rename from lib/entities/bip_353_record.dart rename to lib/core/address_resolver/bip_353/bip_353_record.dart index 9a2a50e0a..257d76737 100644 --- a/lib/entities/bip_353_record.dart +++ b/lib/core/address_resolver/bip_353/bip_353_record.dart @@ -2,13 +2,9 @@ import 'dart:convert'; import 'dart:isolate'; import 'package:basic_utils/basic_utils.dart'; -import 'package:cake_wallet/generated/i18n.dart'; -import 'package:cake_wallet/src/widgets/alert_with_picker_option.dart'; -import 'package:cake_wallet/utils/show_pop_up.dart'; import 'package:cw_core/crypto_currency.dart'; import 'package:cw_core/utils/print_verbose.dart'; import 'package:dnssec_proof/dnssec_proof.dart'; -import 'package:flutter/material.dart'; class Bip353Record { Bip353Record({ @@ -104,69 +100,4 @@ class Bip353Record { } } - static Future pickBip353AddressChoice( - BuildContext context, - String bip353Name, - Map addressMap, - ) async { - if (addressMap.length == 1) { - return addressMap.values.first; - } - - final chosenAddress = await _showAddressChoiceDialog(context, bip353Name, addressMap); - - return chosenAddress; - } - - static Future _showAddressChoiceDialog( - BuildContext context, - String bip353Name, - Map addressMap, - ) async { - final entriesList = addressMap.entries.toList(); - final List> displayItems = entriesList.map((entry) { - final originalKey = entry.key; - final originalValue = entry.value; - - final extendedKeyName = keyDisplayMap[originalKey] ?? originalKey; - final truncatedValue = _truncate(originalValue, front: 6, back: 6); - - return { - 'displayKey': extendedKeyName, - 'displayValue': truncatedValue, - 'originalKey': originalKey, - 'originalValue': originalValue, - }; - }).toList(); - - String? selectedOriginalValue; - - if (context.mounted) { - await showPopUp( - context: context, - builder: (dialogContext) { - return AlertWithPickerOption( - alertTitle: S.of(context).multiple_addresses_detected + '\n$bip353Name', - alertTitleTextSize: 14, - alertSubtitle: S.of(context).please_choose_one + ':', - options: displayItems, - onOptionSelected: (Map chosenItem) { - selectedOriginalValue = chosenItem['originalValue']; - }, - alertBarrierDismissible: true, - ); - }, - ); - } - return selectedOriginalValue; - } - - static String _truncate(String value, {int front = 6, int back = 6}) { - if (value.length <= front + back) return value; - - final start = value.substring(0, front); - final end = value.substring(value.length - back); - return '$start...$end'; - } - -} +} \ No newline at end of file diff --git a/lib/core/address_resolver/ens/ens_address_provider.dart b/lib/core/address_resolver/ens/ens_address_provider.dart new file mode 100644 index 000000000..b0a365d07 --- /dev/null +++ b/lib/core/address_resolver/ens/ens_address_provider.dart @@ -0,0 +1,49 @@ +import 'package:cake_wallet/core/address_resolver/address_lookup_provider.dart'; +import 'package:cake_wallet/core/address_resolver/address_sources.dart'; +import 'package:cake_wallet/core/address_resolver/ens/ens_record.dart'; +import 'package:cake_wallet/core/address_resolver/parsed_address.dart'; +import 'package:cake_wallet/store/settings_store.dart'; +import 'package:cw_core/crypto_currency.dart'; +import 'package:cw_core/wallet_base.dart'; + +class EnsAddressProvider extends AddressLookupProvider { + @override + AddressSource get source => AddressSource.ens; + + @override + List get supportedCurrencies => + [CryptoCurrency.xmr, CryptoCurrency.btc, CryptoCurrency.eth]; + + @override + bool isEnabled(SettingsStore settingsStore) => settingsStore.lookupsENS; + + @override + bool canHandle(String query) => query.endsWith('.eth'); // ENS handle example: name.eth + + @override + Future> resolve({ + required String query, + required List currencies, + required WalletBase wallet, + }) async { + final Map result = {}; + + for (final cur in currencies) { + final address = await EnsRecord.fetchEnsAddress(query, cur, wallet: wallet); + if (address.isNotEmpty && address != "0x0000000000000000000000000000000000000000") { + result[cur] = address; + } + } + + if (result.isNotEmpty) { + return [ + ParsedAddress( + parsedAddressByCurrencyMap: result, + addressSource: AddressSource.ens, + handle: query, + ) + ]; + } + return []; + } +} diff --git a/lib/core/address_resolver/ens/ens_record.dart b/lib/core/address_resolver/ens/ens_record.dart new file mode 100644 index 000000000..b976ae63c --- /dev/null +++ b/lib/core/address_resolver/ens/ens_record.dart @@ -0,0 +1,55 @@ +import 'package:cake_wallet/evm/evm.dart'; +import 'package:cw_core/crypto_currency.dart'; +import 'package:cw_core/utils/proxy_wrapper.dart'; +import 'package:cw_core/utils/print_verbose.dart'; +import 'package:cw_core/wallet_base.dart'; +import 'package:cw_core/wallet_type.dart'; +import 'package:ens_dart/ens_dart.dart'; +import 'package:web3dart/web3dart.dart'; + +class EnsRecord { + static Future fetchEnsAddress( + String name, + CryptoCurrency cur, { + WalletBase? wallet, + }) async { + Web3Client? _client; + + if (wallet?.type == WalletType.ethereum || wallet?.type == WalletType.polygon) { + _client = evm!.getWeb3Client(wallet!); + } + + _client ??= Web3Client( + 'https://ethereum-rpc.publicnode.com', + ProxyWrapper().getHttpIOClient(), + ); + + final ens = Ens(client: _client); + final coinType = getEnsCoinType(cur); + + if (coinType == null) { + printV('Unsupported currency for ENS: $cur'); + return ''; + } + + try { + if (coinType == CoinType.ETH || coinType == CoinType.MATIC) { + return (await ens.withName(name).getAddress()).hex; + } else { + return await ens.withName(name).getCoinAddress(coinType); + } + } catch (e) { + printV(e); + return ''; + } + } + + static CoinType? getEnsCoinType(CryptoCurrency cur) => switch (cur) { + CryptoCurrency.xmr => CoinType.XMR, + CryptoCurrency.btc => CoinType.BTC, + CryptoCurrency.ltc => CoinType.LTC, + CryptoCurrency.eth => CoinType.ETH, + CryptoCurrency.matic => CoinType.MATIC, + _ => null, + }; +} \ No newline at end of file diff --git a/lib/core/address_resolver/fio/fio_address_provider.dart b/lib/core/address_resolver/fio/fio_address_provider.dart new file mode 100644 index 000000000..91d4ac722 --- /dev/null +++ b/lib/core/address_resolver/fio/fio_address_provider.dart @@ -0,0 +1,125 @@ +import 'dart:convert'; + +import 'package:cake_wallet/core/address_resolver/address_lookup_provider.dart'; +import 'package:cake_wallet/core/address_resolver/address_sources.dart'; +import 'package:cake_wallet/core/address_resolver/parsed_address.dart'; +import 'package:cake_wallet/core/address_validator.dart'; +import 'package:cake_wallet/store/settings_store.dart'; +import 'package:cw_core/crypto_currency.dart'; +import 'package:cw_core/utils/print_verbose.dart'; +import 'package:cw_core/utils/proxy_wrapper.dart'; +import 'package:cw_core/wallet_base.dart'; + +class FioAddressProvider extends AddressLookupProvider { + @override + AddressSource get source => AddressSource.fio; + + @override + List get supportedCurrencies => AddressValidator.reliableValidateCurrencies; + + @override + bool canHandle(String q) => + !q.startsWith('@') && + q.contains('@') && + !q.contains('.'); // FIO handle example: username@domain + + @override + bool isEnabled(SettingsStore settingsStore) => settingsStore.lookupsFio; + + @override + Future> resolve({ + required String query, + required List currencies, + required WalletBase wallet, + }) async { + final Map result = {}; + try { + final bool isFioRegistered = await FioAddressProvider.checkAvail(query); + if (!isFioRegistered) return []; + + for (final cur in currencies) { + final address = await FioAddressProvider.getPubAddress(query, cur.title); + if (address != null && address.isNotEmpty) { + result[cur] = address; + } + } + + if (result.isNotEmpty) { + return [ + ParsedAddress( + parsedAddressByCurrencyMap: result, addressSource: AddressSource.fio, handle: query) + ]; + } + return []; + } catch (e) { + printV('FioAddressProvider.resolve error: $e'); + return []; + } + } + + static const _apiAuthority = 'fio.blockpane.com'; + static const _availCheck = '/v1/chain/avail_check'; + static const _getAddress = '/v1/chain/get_pub_address'; + + static Future checkAvail(String fioAddress) async { + bool isFioRegistered = false; + final headers = {'Content-Type': 'application/json'}; + final body = {"fio_name": fioAddress}; + + final uri = Uri.https(_apiAuthority, _availCheck); + final response = await ProxyWrapper().post( + clearnetUri: uri, + headers: headers, + body: json.encode(body), + ); + + if (response.statusCode != 200) { + return isFioRegistered; + } + + final responseJSON = json.decode(response.body) as Map; + isFioRegistered = responseJSON['is_registered'] as int == 1; + + return isFioRegistered; + } + + static Future getPubAddress(String fioAddress, String token) async { + final headers = {'Content-Type': 'application/json'}; + final body = { + "fio_address": fioAddress, + "chain_code": token.toUpperCase(), + "token_code": token.toUpperCase(), + }; + + final uri = Uri.https(_apiAuthority, _getAddress); + final response = await ProxyWrapper().post( + clearnetUri: uri, + headers: headers, + body: json.encode(body), + ); + + final responseJSON = json.decode(response.body) as Map; + + if (response.statusCode == 400) { + final error = responseJSON['error'] as String; + final message = responseJSON['message'] as String; + printV('${error}\n$message'); + return null; + } + + if (response.statusCode != 200) { + final String message = responseJSON['message'] as String? ?? 'Unknown error'; + + printV('Error fetching public address for token $token: $message'); + return null; + } + + final String pubAddress = responseJSON['public_address'] as String? ?? ''; + + if (pubAddress.isNotEmpty) { + return pubAddress; + } + + return null; + } +} diff --git a/lib/core/address_resolver/lnurl_pay/lnurl_pay_address_provider.dart b/lib/core/address_resolver/lnurl_pay/lnurl_pay_address_provider.dart new file mode 100644 index 000000000..b767c15b9 --- /dev/null +++ b/lib/core/address_resolver/lnurl_pay/lnurl_pay_address_provider.dart @@ -0,0 +1,66 @@ +import 'package:cake_wallet/core/address_resolver/address_lookup_provider.dart'; +import 'package:cake_wallet/core/address_resolver/address_sources.dart'; +import 'package:cake_wallet/core/address_resolver/lnurl_pay/lnurlpay_record.dart'; +import 'package:cake_wallet/core/address_resolver/parsed_address.dart'; +import 'package:cake_wallet/store/settings_store.dart'; +import 'package:cw_core/crypto_currency.dart'; +import 'package:cw_core/utils/print_verbose.dart'; +import 'package:cw_core/wallet_base.dart'; +import 'package:cw_core/wallet_type.dart'; + +class LNUrlPayAddressProvider extends AddressLookupProvider { + @override + AddressSource get source => AddressSource.lnurlPay; + + @override + List get supportedCurrencies => [CryptoCurrency.btcln]; + + @override + bool canHandle(String q) => q.contains('.') && q.contains('@'); // LNURL-pay handle example: user@domain.com + + @override + bool isEnabled(SettingsStore settingsStore) => settingsStore.lookupsLNUrl; + + @override + Future> resolve({ + required String query, + required List currencies, + required WalletBase wallet, + }) async { + try { + if (wallet.type != WalletType.bitcoin) return []; + + final formattedName = query.trim(); + if (formattedName.isEmpty) return []; + + final result = {}; + + for (final currency in currencies) { + if (!supportedCurrencies.contains(currency)) continue; + + final record = await LNUrlPayRecord.fetchAddressAndName( + formattedName: formattedName, + currency: currency, + ); + + if (record != null && record.address.isNotEmpty) { + result[currency] = record.address; + } + } + + if (result.isEmpty) return []; + + return [ + ParsedAddress( + parsedAddressByCurrencyMap: result, + addressSource: AddressSource.lnurlPay, + handle: formattedName, + profileName: formattedName, + ), + ]; + } catch (e) { + printV('[address resolver] Error resolving LNURL-pay address: $e'); + return []; + } + } +} \ No newline at end of file diff --git a/lib/entities/lnurlpay_record.dart b/lib/core/address_resolver/lnurl_pay/lnurlpay_record.dart similarity index 100% rename from lib/entities/lnurlpay_record.dart rename to lib/core/address_resolver/lnurl_pay/lnurlpay_record.dart diff --git a/lib/core/address_resolver/mastodon/mastodon_address_provider.dart b/lib/core/address_resolver/mastodon/mastodon_address_provider.dart new file mode 100644 index 000000000..10c226d0e --- /dev/null +++ b/lib/core/address_resolver/mastodon/mastodon_address_provider.dart @@ -0,0 +1,93 @@ +import 'package:cake_wallet/core/address_resolver/address_lookup_provider.dart'; +import 'package:cake_wallet/core/address_resolver/address_resolver_utils.dart'; +import 'package:cake_wallet/core/address_resolver/address_sources.dart'; +import 'package:cake_wallet/core/address_resolver/mastodon/mastodon_api.dart'; +import 'package:cake_wallet/core/address_resolver/parsed_address.dart'; +import 'package:cake_wallet/core/address_validator.dart'; +import 'package:cake_wallet/store/settings_store.dart'; +import 'package:cw_core/crypto_currency.dart'; +import 'package:cw_core/utils/print_verbose.dart'; +import 'package:cw_core/wallet_base.dart'; + +class MastodonAddressProvider extends AddressLookupProvider { + @override + AddressSource get source => AddressSource.mastodon; + + @override + List get supportedCurrencies => AddressValidator.reliableValidateCurrencies; + + @override + bool isEnabled(SettingsStore settingsStore) => settingsStore.lookupsMastodon; + + @override + bool canHandle(String query) => + query.startsWith('@') && + query.contains('@', 1) && + query.contains('.', 1); // Mastodon handle example: @username@hostname.xxx + + @override + Future> resolve({ + required String query, + required List currencies, + required WalletBase wallet, + }) async { + try { + final subText = query.substring(1); // Remove '@' from the beginning of the handle + final hostNameIndex = subText.indexOf('@'); + if (hostNameIndex <= 0 || hostNameIndex == subText.length - 1) return []; + + final userName = subText.substring(0, hostNameIndex); + final hostName = subText.substring(hostNameIndex + 1); + + final result = {}; + + final mastodonUser = await MastodonAPI.lookupUserByUserName( + userName: userName, + apiHost: hostName, + ); + if (mastodonUser == null) return []; + + final bio = AddressResolverUtils.stripHtmlTags(mastodonUser.note); + result.addAll( + AddressResolverUtils.extractAddressesFromText( + raw: bio, + currencies: currencies, + ), + ); + + final pinnedPosts = await MastodonAPI.getPinnedPosts( + userId: mastodonUser.id, + apiHost: hostName, + ); + + if (pinnedPosts.isNotEmpty) { + final pinnedPostsText = pinnedPosts + .map((item) => AddressResolverUtils.stripHtmlTags(item.content)) + .where((text) => text.isNotEmpty) + .join('\n'); + + result.addAll( + AddressResolverUtils.extractAddressesFromText( + raw: pinnedPostsText, + currencies: currencies, + ), + ); + } + + if (result.isEmpty) return []; + + return [ + ParsedAddress( + parsedAddressByCurrencyMap: result, + addressSource: AddressSource.mastodon, + handle: query, + profileImageUrl: mastodonUser.profileImageUrl, + profileName: mastodonUser.username, + ), + ]; + } catch (e) { + printV('[address resolver] Error resolving Mastodon address: $e'); + return []; + } + } +} diff --git a/lib/mastodon/mastodon_api.dart b/lib/core/address_resolver/mastodon/mastodon_api.dart similarity index 95% rename from lib/mastodon/mastodon_api.dart rename to lib/core/address_resolver/mastodon/mastodon_api.dart index 58767eea7..6cb60be81 100644 --- a/lib/mastodon/mastodon_api.dart +++ b/lib/core/address_resolver/mastodon/mastodon_api.dart @@ -1,7 +1,7 @@ import 'dart:convert'; +import 'package:cake_wallet/core/address_resolver/mastodon/mastodon_user.dart'; import 'package:cw_core/utils/proxy_wrapper.dart'; import 'package:cw_core/utils/print_verbose.dart'; -import 'package:cake_wallet/mastodon/mastodon_user.dart'; class MastodonAPI { static const httpsScheme = 'https'; diff --git a/lib/mastodon/mastodon_user.dart b/lib/core/address_resolver/mastodon/mastodon_user.dart similarity index 100% rename from lib/mastodon/mastodon_user.dart rename to lib/core/address_resolver/mastodon/mastodon_user.dart diff --git a/lib/core/address_resolver/nostr/nostr_address_provider.dart b/lib/core/address_resolver/nostr/nostr_address_provider.dart new file mode 100644 index 000000000..332187e9e --- /dev/null +++ b/lib/core/address_resolver/nostr/nostr_address_provider.dart @@ -0,0 +1,66 @@ +import 'package:cake_wallet/core/address_resolver/address_lookup_provider.dart'; +import 'package:cake_wallet/core/address_resolver/address_resolver_utils.dart'; +import 'package:cake_wallet/core/address_resolver/address_sources.dart'; +import 'package:cake_wallet/core/address_resolver/nostr/nostr_api.dart'; +import 'package:cake_wallet/core/address_resolver/parsed_address.dart'; +import 'package:cake_wallet/core/address_validator.dart'; +import 'package:cake_wallet/store/settings_store.dart'; +import 'package:cw_core/crypto_currency.dart'; +import 'package:cw_core/utils/print_verbose.dart'; +import 'package:cw_core/wallet_base.dart'; + +class NostrAddressProvider extends AddressLookupProvider { + @override + AddressSource get source => AddressSource.nostr; + + @override + List get supportedCurrencies => AddressValidator.reliableValidateCurrencies; + + @override + bool canHandle(String q) => + AddressResolverUtils.isEmailFormat(q); // Nostr handle example: username@domain + + @override + bool isEnabled(SettingsStore settingsStore) => settingsStore.lookupsNostr; + + @override + Future> resolve({ + required String query, + required List currencies, + required WalletBase wallet, + }) async { + try { + final profile = await NostrProfileHandler.queryProfile(query); + if (profile == null) return []; + + final data = await NostrProfileHandler.processRelays(profile, query); + if (data == null) return []; + + final result = {}; + + String queryTxt = data.about; + + for (final cur in currencies) { + final addr = AddressResolverUtils.extractAddressByType(raw: queryTxt, type: cur); + if (addr != null && addr.isNotEmpty) { + result[cur] = addr; + queryTxt = queryTxt.replaceFirst(addr, ''); + } + } + if (result.isEmpty) return []; + + return [ + ParsedAddress( + parsedAddressByCurrencyMap: result, + addressSource: AddressSource.nostr, + handle: query, + profileImageUrl: data.picture, + profileName: data.name, + ) + ]; + } catch (e) { + printV('Error looking up Nostr profile: $e'); + return []; + } + } +} diff --git a/lib/core/address_resolver/nostr/nostr_api.dart b/lib/core/address_resolver/nostr/nostr_api.dart new file mode 100644 index 000000000..19d25c01f --- /dev/null +++ b/lib/core/address_resolver/nostr/nostr_api.dart @@ -0,0 +1,127 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:cake_wallet/core/address_resolver/nostr/nostr_user.dart'; +import 'package:nostr_tools/nostr_tools.dart'; +import 'dart:async' show Completer, TimeoutException, runZonedGuarded; + +class NostrProfileHandler { + static final relayToDomainMap = { + 'relay.snort.social': 'snort.social', + }; + + static final Nip05 _nip05 = Nip05(); + + static Future queryProfile(String nip05Address) async { + final profile = await _nip05.queryProfile(nip05Address); + if (profile?.pubkey != null && profile?.relays?.isNotEmpty == true) { + return profile; + } + return null; + } + + static Future processRelays( + ProfilePointer profile, + String nip05Address, + ) async { + final userDomain = _extractDomain(nip05Address); + const int metaKind = 0; + + // Domain-matched relays first + for (final String relayUrl in profile.relays ?? []) { + final relayDomain = + relayToDomainMap[_getDomainFromRelayUrl(relayUrl)] ?? _getDomainFromRelayUrl(relayUrl); + + if (relayDomain == userDomain) { + final data = await _fetchInfoFromRelay(relayUrl, profile.pubkey, [metaKind]); + if (data != null) return data; + } + } + + // Then try every remaining relay + for (final String relayUrl in profile.relays ?? []) { + final data = await _fetchInfoFromRelay(relayUrl, profile.pubkey, [metaKind]); + if (data != null) return data; + } + + // Nothing found + return null; + } + + static const Duration _relayTimeout = Duration(seconds: 3); + + static Future _fetchInfoFromRelay( + String relayUrl, String userPubKey, List kinds) async { + // sanitize so obvious junk (like '#') doesn't reach connect() + final clean = _sanitizeRelay(relayUrl); + if (clean.isEmpty) return null; + + final result = Completer(); + + runZonedGuarded(() async { + try { + final relay = RelayApi(relayUrl: clean); + + final stream = await relay.connect().timeout( + _relayTimeout, + onTimeout: () { + relay.close(); + throw TimeoutException('Relay connect timeout'); + }, + ); + + relay.sub([ + Filter(kinds: kinds, authors: [userPubKey]) + ]); + + final sub = stream.listen((msg) { + if (msg.type == 'EVENT' && !result.isCompleted) { + try { + final event = msg.message as Event; + final jsonMap = json.decode(event.content) as Map; + result.complete(UserMetadata.fromJson(jsonMap)); + } catch (_) { + if (!result.isCompleted) result.complete(null); + } + } + }, onError: (_) { + if (!result.isCompleted) result.complete(null); + }, onDone: () { + if (!result.isCompleted) result.complete(null); + }); + + final value = await result.future.timeout(_relayTimeout, onTimeout: () => null); + await sub.cancel(); + relay.close(); + if (!result.isCompleted) result.complete(value); + } catch (_) { + if (!result.isCompleted) result.complete(null); + } + }, (error, stack) { + // swallow ALL async errors from the websocket layer (including "was not upgraded to websocket") + if (!result.isCompleted) result.complete(null); + }); + + return result.future; + } + + static String _sanitizeRelay(String url) { + url = url.replaceFirst(RegExp(r'^https?://'), 'wss://'); + final uri = Uri.parse(url); + return Uri( + scheme: uri.scheme.isEmpty ? 'wss' : uri.scheme, + host: uri.host, + port: uri.hasPort ? uri.port : 443, + ).toString(); + } + + static String _extractDomain(String nip05) => nip05.split('@').last; + + static String _getDomainFromRelayUrl(String url) { + try { + return Uri.parse(url).host; + } catch (_) { + return ''; + } + } +} \ No newline at end of file diff --git a/lib/nostr/nostr_user.dart b/lib/core/address_resolver/nostr/nostr_user.dart similarity index 100% rename from lib/nostr/nostr_user.dart rename to lib/core/address_resolver/nostr/nostr_user.dart diff --git a/lib/core/address_resolver/openalias/openalias_address_provider.dart b/lib/core/address_resolver/openalias/openalias_address_provider.dart new file mode 100644 index 000000000..6f7e8671b --- /dev/null +++ b/lib/core/address_resolver/openalias/openalias_address_provider.dart @@ -0,0 +1,64 @@ +import 'package:cake_wallet/core/address_resolver/address_lookup_provider.dart'; +import 'package:cake_wallet/core/address_resolver/address_sources.dart'; +import 'package:cake_wallet/core/address_resolver/openalias/openalias_record.dart'; +import 'package:cake_wallet/core/address_resolver/parsed_address.dart'; +import 'package:cake_wallet/store/settings_store.dart'; +import 'package:cw_core/crypto_currency.dart'; +import 'package:cw_core/utils/print_verbose.dart'; +import 'package:cw_core/wallet_base.dart'; + +class OpenaliasAddressProvider extends AddressLookupProvider { + @override + AddressSource get source => AddressSource.openAlias; + + @override + List get supportedCurrencies => [CryptoCurrency.xmr, CryptoCurrency.btc]; + + @override + bool canHandle(String q) { + final formattedName = OpenaliasRecord.formatDomainName(q); + return formattedName.contains("."); + } + + @override + bool isEnabled(SettingsStore settingsStore) => settingsStore.lookupsOpenAlias; + + @override + Future> resolve({ + required String query, + required List currencies, + required WalletBase wallet, + }) async { + try { + final formatted = OpenaliasRecord.formatDomainName(query); + + final txtRecords = await OpenaliasRecord.lookupOpenAliasRecord(formatted); + if (txtRecords == null) return []; + + final result = {}; + + for (final cur in currencies) { + final rec = OpenaliasRecord.fetchAddressAndName( + formattedName: formatted, + ticker: cur.title.toLowerCase(), + txtRecord: txtRecords, + ); + + if (rec.address.isNotEmpty) result[cur] = rec.address; + } + + if (result.isEmpty) return []; + + return [ + ParsedAddress( + parsedAddressByCurrencyMap: result, + addressSource: AddressSource.openAlias, + handle: query, + ) + ]; + } catch (e) { + printV('[address resolver] Error resolving OpenAlias address: $e'); + return []; + } + } +} diff --git a/lib/core/address_resolver/openalias/openalias_record.dart b/lib/core/address_resolver/openalias/openalias_record.dart new file mode 100644 index 000000000..65273df3e --- /dev/null +++ b/lib/core/address_resolver/openalias/openalias_record.dart @@ -0,0 +1,69 @@ +import 'package:basic_utils/basic_utils.dart'; +import 'package:cw_core/utils/print_verbose.dart'; + +class OpenaliasRecord { + OpenaliasRecord({ + required this.address, + required this.name, + required this.description, + }); + + final String name; + final String address; + final String description; + + static String formatDomainName(String name) { + String formattedName = name; + + if (name.contains("@")) { + formattedName = name.replaceAll("@", "."); + } + + return formattedName; + } + + static Future?> lookupOpenAliasRecord(String name) async { + try { + final txtRecord = await DnsUtils.lookupRecord(name, RRecordType.TXT, dnssec: true); + + return txtRecord; + } catch (e) { + printV("${e.toString()}"); + return null; + } + } + + static OpenaliasRecord fetchAddressAndName({ + required String formattedName, + required String ticker, + required List txtRecord, + }) { + var address = ''; + var name = formattedName; + var note = ''; + + final addrRe = RegExp(r'recipient_address=([^;\s]+)', caseSensitive: false); + final nameRe = RegExp(r'recipient_name=([^;]+)', caseSensitive: false); + final noteRe = RegExp(r'tx_description=([^;]+)', caseSensitive: false); + final tag = 'oa1:$ticker'; + + for (final rr in txtRecord) { + final txt = rr.data.replaceAll('"', ''); + + if (!txt.toLowerCase().contains(tag)) continue; + + final addrM = addrRe.firstMatch(txt); + if (addrM != null) address = addrM.group(1)!; + + final nameM = nameRe.firstMatch(txt); + if (nameM != null) name = nameM.group(1)!.trim(); + + final noteM = noteRe.firstMatch(txt); + if (noteM != null) note = noteM.group(1)!.trim(); + + break; + } + + return OpenaliasRecord(address: address, name: name, description: note); + } +} \ No newline at end of file diff --git a/lib/core/address_resolver/parsed_address.dart b/lib/core/address_resolver/parsed_address.dart new file mode 100644 index 000000000..8934e2d76 --- /dev/null +++ b/lib/core/address_resolver/parsed_address.dart @@ -0,0 +1,22 @@ +import 'package:cake_wallet/core/address_resolver/address_sources.dart'; +import 'package:cw_core/crypto_currency.dart'; + +class ParsedAddress { + const ParsedAddress({ + required this.parsedAddressByCurrencyMap, + this.addressSource = AddressSource.notParsed, + this.handle = '', + this.profileImageUrl = '', + this.profileName = '', + this.description = '', + this.bip353DnsProof, + }); + + final Map parsedAddressByCurrencyMap; + final AddressSource addressSource; + final String handle; + final String profileImageUrl; + final String profileName; + final String description; + final String? bip353DnsProof; +} \ No newline at end of file diff --git a/lib/core/address_resolver/thorchain/thorchain_address_provider.dart b/lib/core/address_resolver/thorchain/thorchain_address_provider.dart new file mode 100644 index 000000000..21c27217a --- /dev/null +++ b/lib/core/address_resolver/thorchain/thorchain_address_provider.dart @@ -0,0 +1,65 @@ +import 'package:cake_wallet/core/address_resolver/address_lookup_provider.dart'; +import 'package:cake_wallet/core/address_resolver/address_resolver_utils.dart'; +import 'package:cake_wallet/core/address_resolver/address_sources.dart'; +import 'package:cake_wallet/core/address_resolver/parsed_address.dart'; +import 'package:cake_wallet/core/address_validator.dart'; +import 'package:cake_wallet/exchange/provider/thorchain_exchange.provider.dart'; +import 'package:cake_wallet/store/settings_store.dart'; +import 'package:cw_core/crypto_currency.dart'; +import 'package:cw_core/utils/print_verbose.dart'; +import 'package:cw_core/wallet_base.dart'; + +class ThorchainAddressProvider extends AddressLookupProvider { + @override + AddressSource get source => AddressSource.thorChain; + + @override + List get supportedCurrencies => AddressValidator.reliableValidateCurrencies; + + @override + bool canHandle(String q) => q.isNotEmpty; // can be any string + + @override + bool isEnabled(SettingsStore settingsStore) => settingsStore.lookupsThorChain; + + @override + Future> resolve({ + required String query, + required List currencies, + required WalletBase wallet, + }) async { + try { + final isNormalAddress = currencies.any((cur) => + AddressResolverUtils.extractAddressByType(raw: query, type: cur)?.isNotEmpty ?? false); + if (query.length > 30 || isNormalAddress) return []; + + final map = await ThorChainExchangeProvider.lookupAddressByName(query); + if (map == null || map.isEmpty) return []; + + final result = {}; + + for (final cur in currencies) { + final key = cur.title.toUpperCase(); + final addr = map[key]; + final runeAddr = cur.title.toUpperCase() == 'RUNE' ? map['THOR'] : null; + final resolvedAddress = addr ?? runeAddr; + if (resolvedAddress != null && resolvedAddress.isNotEmpty) { + if (!result.containsValue(resolvedAddress)) result[cur] = resolvedAddress; + } + } + + if (result.isEmpty) return []; + + return [ + ParsedAddress( + parsedAddressByCurrencyMap: result, + addressSource: AddressSource.thorChain, + handle: query, + ) + ]; + } catch (e) { + printV('[address resolver] Error resolving address: $e'); + return []; + } + } +} diff --git a/lib/core/address_resolver/twitter/twitter_address_provider.dart b/lib/core/address_resolver/twitter/twitter_address_provider.dart new file mode 100644 index 000000000..954bfb6ec --- /dev/null +++ b/lib/core/address_resolver/twitter/twitter_address_provider.dart @@ -0,0 +1,97 @@ +import 'package:cake_wallet/core/address_resolver/address_lookup_provider.dart'; +import 'package:cake_wallet/core/address_resolver/address_resolver_utils.dart'; +import 'package:cake_wallet/core/address_resolver/address_sources.dart'; +import 'package:cake_wallet/core/address_resolver/parsed_address.dart'; +import 'package:cake_wallet/core/address_resolver/twitter/twitter_api.dart'; +import 'package:cake_wallet/core/address_resolver/twitter/twitter_user.dart'; +import 'package:cake_wallet/core/address_validator.dart'; +import 'package:cake_wallet/store/settings_store.dart'; +import 'package:cw_core/crypto_currency.dart'; +import 'package:cw_core/utils/print_verbose.dart'; +import 'package:cw_core/wallet_base.dart'; + +class TwitterAddressProvider extends AddressLookupProvider { + + @override + AddressSource get source => AddressSource.twitter; + + @override + List get supportedCurrencies => AddressValidator.reliableValidateCurrencies; + + @override + bool isEnabled(SettingsStore settingsStore) => settingsStore.lookupsTwitter; + + @override + bool canHandle(String query) => + query.startsWith('@') && !query.substring(1).contains('@'); // x handle example: @username + + /// Resolves a Twitter handle to a list of ParsedAddress objects by extracting potential cryptocurrency addresses + /// from the user's, profile. + /// fallback to resolving Unstoppable Domains if no addresses are found in each of the Twitter user's bio, location, and pinned tweet. + + @override + Future> resolve({ + required String query, + required List currencies, + required WalletBase wallet, + }) async { + final formattedName = query.replaceFirst("@", ""); + try { + final result = {}; + + final twitterUser = await TwitterApi.lookupUserByName(userName: formattedName); + + final bio = (text: twitterUser.description, whitespaces: true); + final location = (text: twitterUser.location, whitespaces: false); + final pinnedTweet = (text: twitterUser.pinnedTweet?.text ?? '', whitespaces: true); + + final twitterTexts = [ + bio, + location, + pinnedTweet, + ].where((item) => item.text.isNotEmpty); + + for (final item in twitterTexts) { + result.addAll( + AddressResolverUtils.extractAddressesFromText( + raw: item.text, + currencies: currencies, + requireSurroundingWhitespaces: item.whitespaces, + ), + ); + + if (result.isEmpty) { + final domainResult = await AddressResolverUtils.resolveUnstoppableDomainFromText( + raw: item.text, + currencies: currencies, + ); + + if (domainResult.isNotEmpty) { + return [_buildTwitterParsedAddress(query, twitterUser, domainResult)]; + } + } + } + + if (result.isEmpty) return []; + + return [_buildTwitterParsedAddress(query, twitterUser, result)]; + } catch (e) { + printV('[address resolver service] Twitter error: $e'); + return []; + } + } + + ParsedAddress _buildTwitterParsedAddress( + String query, + TwitterUser twitterUser, + Map result, + ) { + return ParsedAddress( + parsedAddressByCurrencyMap: result, + addressSource: AddressSource.twitter, + handle: query, + profileImageUrl: twitterUser.profileImageUrl, + profileName: twitterUser.name, + ); + } +} diff --git a/lib/twitter/twitter_api.dart b/lib/core/address_resolver/twitter/twitter_api.dart similarity index 96% rename from lib/twitter/twitter_api.dart rename to lib/core/address_resolver/twitter/twitter_api.dart index a508b3edf..0079d39a9 100644 --- a/lib/twitter/twitter_api.dart +++ b/lib/core/address_resolver/twitter/twitter_api.dart @@ -1,7 +1,7 @@ import 'dart:convert'; import 'package:cake_wallet/.secrets.g.dart' as secrets; -import 'package:cake_wallet/twitter/twitter_user.dart'; +import 'package:cake_wallet/core/address_resolver/twitter/twitter_user.dart'; import 'package:cw_core/utils/proxy_wrapper.dart'; class TwitterApi { diff --git a/lib/twitter/twitter_user.dart b/lib/core/address_resolver/twitter/twitter_user.dart similarity index 100% rename from lib/twitter/twitter_user.dart rename to lib/core/address_resolver/twitter/twitter_user.dart diff --git a/lib/core/address_resolver/unstoppable/unstoppable_address_provider.dart b/lib/core/address_resolver/unstoppable/unstoppable_address_provider.dart new file mode 100644 index 000000000..dd240f7ed --- /dev/null +++ b/lib/core/address_resolver/unstoppable/unstoppable_address_provider.dart @@ -0,0 +1,235 @@ +import 'dart:convert'; + +import 'package:cake_wallet/core/address_resolver/address_lookup_provider.dart'; +import 'package:cake_wallet/core/address_resolver/address_sources.dart'; +import 'package:cake_wallet/core/address_resolver/openalias/openalias_record.dart'; +import 'package:cake_wallet/core/address_resolver/parsed_address.dart'; +import 'package:cake_wallet/store/settings_store.dart'; +import 'package:cw_core/crypto_currency.dart'; +import 'package:cw_core/utils/print_verbose.dart'; +import 'package:cw_core/utils/proxy_wrapper.dart'; +import 'package:cw_core/wallet_base.dart'; + +class UnstoppableAddressProvider extends AddressLookupProvider { + static const unstoppableDomains = [ + "888", + "academy", + "agency", + "altimist", + "anime", + "austin", + "bald", + "bay", + "benji", + "bet", + "binanceus", + "bitcoin", + "bitget", + "bitscrunch", + "blockchain", + "boomer", + "boston", + "ca", + "caw", + "cc", + "chat", + "chomp", + "clay", + "club", + "co", + "com", + "company", + "crypto", + "dao", + "design", + "dfz", + "digital", + "doga", + "donut", + "dream", + "email", + "emir", + "eth", + "ethermail", + "family", + "farms", + "finance", + "fun", + "fyi", + "games", + "global", + "go", + "group", + "guru", + "hi", + "hockey", + "host", + "info", + "io", + "klever", + "kresus", + "kryptic", + "lfg", + "life", + "live", + "llc", + "ltc", + "ltd", + "manga", + "me", + "media", + "metropolis", + "miami", + "miku", + "money", + "moon", + "mumu", + "net", + "network", + "news", + "nft", + "npc", + "onchain", + "online", + "org", + "podcast", + "pog", + "polygon", + "base", + "arbitrum", + "press", + "privacy", + "pro", + "propykeys", + "pudgy", + "pw", + "quantum", + "rad", + "raiin", + "retardio", + "rip", + "rocks", + "secret", + "services", + "site", + "smobler", + "social", + "solutions", + "space", + "stepn", + "store", + "studio", + "systems", + "tball", + "tea", + "team", + "tech", + "technology", + "today", + "tribe", + "u", + "ubu", + "uno", + "unstoppable", + "vip", + "wallet", + "website", + "wif", + "wifi", + "witg", + "work", + "world", + "wrkx", + "wtf", + "x", + "xmr", + "xyz", + "zil", + "zone", + "pizza" + ]; + + @override + AddressSource get source => AddressSource.unstoppableDomains; + + @override + List get supportedCurrencies => [CryptoCurrency.xmr, CryptoCurrency.btc]; + + @override + bool isEnabled(SettingsStore settingsStore) => settingsStore.lookupsUnstoppableDomains; + + @override + bool canHandle(String query) { + // Unstoppable Domains handle example: name.crypto + final formattedName = OpenaliasRecord.formatDomainName(query); + final domainParts = formattedName.split('.'); + final name = domainParts.last; + return domainParts.length > 1 && + domainParts.first.isNotEmpty && + name.isNotEmpty && + unstoppableDomains.any((domain) => name.trim() == domain); + } + + @override + Future> resolve({ + required String query, + required List currencies, + required WalletBase wallet, + }) async { + try { + final formattedName = OpenaliasRecord.formatDomainName(query); + final result = {}; + + for (final currency in currencies) { + final address = await fetchUnstoppableDomainAddress( + formattedName, + currency.title, + ); + + if (address.isNotEmpty) { + result[currency] = address; + } + } + + if (result.isEmpty) return []; + + return [ + ParsedAddress( + parsedAddressByCurrencyMap: result, + profileName: formattedName, + addressSource: AddressSource.unstoppableDomains, + handle: formattedName, + ), + ]; + } catch (e) { + printV('[address resolver] Error resolving Unstoppable Domain: $e'); + return []; + } + } + + static Future fetchUnstoppableDomainAddress(String domain, String ticker) async { + var address = ''; + + try { + final uri = Uri.parse("https://api.unstoppabledomains.com/profile/public/${Uri.encodeQueryComponent(domain)}?fields=records"); + final response = await ProxyWrapper().get(clearnetUri: uri); + + final jsonParsed = json.decode(response.body) as Map; + if (jsonParsed["records"] == null) { + throw Exception(".records response from $uri is empty"); + }; + final records = jsonParsed["records"] as Map; + final key = "crypto.${ticker.toUpperCase()}.address"; + if (records[key] == null) { + throw Exception(".records.${key} response from $uri is empty"); + } + + return records[key] as String? ?? ''; + } catch (e) { + printV('Unstoppable domain error: ${e.toString()}'); + address = ''; + } + + return address; + } +} diff --git a/lib/core/address_resolver/wellknown/wellknown_address_provider.dart b/lib/core/address_resolver/wellknown/wellknown_address_provider.dart new file mode 100644 index 000000000..464ffd798 --- /dev/null +++ b/lib/core/address_resolver/wellknown/wellknown_address_provider.dart @@ -0,0 +1,49 @@ +import 'package:cake_wallet/core/address_resolver/address_lookup_provider.dart'; +import 'package:cake_wallet/core/address_resolver/address_sources.dart'; +import 'package:cake_wallet/core/address_resolver/parsed_address.dart'; +import 'package:cake_wallet/core/address_resolver/wellknown/wellknown_record.dart'; +import 'package:cake_wallet/store/settings_store.dart'; +import 'package:cw_core/crypto_currency.dart'; +import 'package:cw_core/utils/print_verbose.dart'; +import 'package:cw_core/wallet_base.dart'; + +class WellKnownAddressProvider extends AddressLookupProvider { + @override + AddressSource get source => AddressSource.wellKnown; + + @override + List get supportedCurrencies => [CryptoCurrency.nano]; + + @override + bool canHandle(String q) => q.contains('.') && q.contains('@'); + + // .well-known handle example: + + @override + bool isEnabled(SettingsStore settingsStore) => settingsStore.lookupsWellKnown; + + @override + Future> resolve({ + required String query, + required List currencies, + required WalletBase wallet, + }) async { + try { + final rec = await WellKnownRecord.fetch(query, CryptoCurrency.nano); + if (rec == null || rec.address.isEmpty) return []; + + return [ + ParsedAddress( + parsedAddressByCurrencyMap: {CryptoCurrency.nano: rec.address}, + addressSource: AddressSource.wellKnown, + handle: query, + profileName: rec.title ?? '', + profileImageUrl: rec.imageUrl ?? '', + ) + ]; + } catch (e) { + printV('[address resolver] Error resolving Well-Known address: $e'); + return []; + } + } +} diff --git a/lib/core/address_resolver/wellknown/wellknown_record.dart b/lib/core/address_resolver/wellknown/wellknown_record.dart new file mode 100644 index 000000000..b2ed2eb9c --- /dev/null +++ b/lib/core/address_resolver/wellknown/wellknown_record.dart @@ -0,0 +1,78 @@ +import 'dart:convert'; + +import 'package:cw_core/crypto_currency.dart'; +import 'package:cw_core/utils/print_verbose.dart'; +import 'package:cw_core/utils/proxy_wrapper.dart'; + +class WellKnownRecord { + WellKnownRecord({ + required this.address, + required this.alias, + required this.imageUrl, + required this.title, + }); + + final String address; + final String alias; + final String? imageUrl; + final String? title; + + static const _jsonLocation = {CryptoCurrency.nano: 'nano-currency'}; + + static Future fetch( + String username, + CryptoCurrency currency, + ) async { + final location = _jsonLocation[currency]; + if (location == null) { + printV('well-known: unsupported coin $currency'); + return null; + } + + final parts = username.split('@'); + if (parts.length < 2) { + printV('well-known: missing @ in "$username"'); + return null; + } + final alias = (parts.length == 3 ? parts[1] : parts[0]).trim(); + final domain = parts.last.trim(); + final encoded = Uri.encodeComponent(alias.isEmpty ? '_' : alias); + + final uri = Uri.https( + domain, + '/.well-known/$location.json', + {'names': encoded}, + ); + + try { + final res = await ProxyWrapper().get( + clearnetUri: uri, + headers: {'Accept': 'application/json'}, + ); + + final cType = res.headers['content-type']?.toLowerCase() ?? ''; + if (res.statusCode != 200 || !cType.contains('application/json')) { + printV('well-known: $uri → ${res.statusCode} $cType'); + return null; + } + + final body = json.decode(res.body) as Map; + final items = body['names'] as List? ?? const []; + + for (final raw in items.whereType()) { + if (raw['name'].toString().toLowerCase() == alias.toLowerCase()) { + return WellKnownRecord( + address: raw['address']?.toString() ?? '', + alias: alias, + imageUrl: raw['image']?.toString(), + title: raw['name']?.toString(), + ); + } + } + printV('well-known: alias "$alias" not found in list'); + } catch (e) { + printV('well-known: network / JSON error → $e'); + } + return null; + } +} \ No newline at end of file diff --git a/lib/core/address_resolver/yat/yat_address_provider.dart b/lib/core/address_resolver/yat/yat_address_provider.dart new file mode 100644 index 000000000..d8ee3454b --- /dev/null +++ b/lib/core/address_resolver/yat/yat_address_provider.dart @@ -0,0 +1,59 @@ +import 'package:cake_wallet/core/address_resolver/address_lookup_provider.dart'; +import 'package:cake_wallet/core/address_resolver/address_sources.dart'; +import 'package:cake_wallet/core/address_resolver/parsed_address.dart'; +import 'package:cake_wallet/core/address_resolver/yat/yat_service.dart'; +import 'package:cake_wallet/entities/emoji_string_extension.dart'; +import 'package:cake_wallet/store/settings_store.dart'; +import 'package:cw_core/crypto_currency.dart'; +import 'package:cw_core/utils/print_verbose.dart'; +import 'package:cw_core/wallet_base.dart'; + +class YatAddressProvider extends AddressLookupProvider { + @override + AddressSource get source => AddressSource.yatRecord; + + @override + List get supportedCurrencies => + [CryptoCurrency.xmr, CryptoCurrency.btc, CryptoCurrency.eth, CryptoCurrency.ltc]; + + @override + bool canHandle(String q) => q.hasOnlyEmojis; // Yat handle example: 🐶🐾 + + @override + bool isEnabled(SettingsStore settingsStore) => settingsStore.lookupsWellKnown; + + @override + Future> resolve({ + required String query, + required List currencies, + required WalletBase wallet, + }) async { + try { + final result = {}; + + for (final cur in currencies) { + final records = await YatService.fetchYatAddress(query, cur.title); + if (records.isEmpty) continue; + + final chosen = cur == CryptoCurrency.xmr + ? records.firstWhere((r) => r.isMoneroSub, orElse: () => records.first) + : records.first; + + result[cur] = chosen.address; + } + + if (result.isEmpty) return []; + + return [ + ParsedAddress( + parsedAddressByCurrencyMap: result, + addressSource: AddressSource.yatRecord, + handle: query, + ) + ]; + } catch (e) { + printV('[address resolver] Error resolving Yat address: $e'); + return []; + } + } +} diff --git a/lib/core/address_resolver/yat/yat_record.dart b/lib/core/address_resolver/yat/yat_record.dart new file mode 100644 index 000000000..412a5a486 --- /dev/null +++ b/lib/core/address_resolver/yat/yat_record.dart @@ -0,0 +1,19 @@ +import 'package:cake_wallet/core/address_resolver/yat/yat_service.dart'; + +class YatRecord { + YatRecord({ + required this.address, + required this.tag, + }); + + final String address; + final String tag; + + factory YatRecord.fromJson(Map json, String tag) => + YatRecord(address: (json['address'] ?? '').toString(), tag: tag); + + bool get isMoneroSub => tag == YatService.MONERO_SUB_ADDRESS; + + @override + String toString() => 'YatRecord(tag: $tag, address: $address)'; +} \ No newline at end of file diff --git a/lib/core/address_resolver/yat/yat_service.dart b/lib/core/address_resolver/yat/yat_service.dart new file mode 100644 index 000000000..84ba4e8f3 --- /dev/null +++ b/lib/core/address_resolver/yat/yat_service.dart @@ -0,0 +1,67 @@ +import 'dart:convert'; + +import 'package:cake_wallet/core/address_resolver/yat/yat_record.dart'; +import 'package:cw_core/utils/proxy_wrapper.dart'; + +class YatService { + static bool isDevMode = false; + + static String get apiUrl => + YatService.isDevMode ? YatService.apiDevUrl : YatService.apiReleaseUrl; + static const apiReleaseUrl = "https://a.y.at"; + static const apiDevUrl = 'https://a.yat.fyi'; + + static String lookupEmojiUrl(String emojiId) => + "$apiUrl/emoji_id/$emojiId/payment"; + + static const String MONERO_SUB_ADDRESS = '0x1002'; + static const String MONERO_STD_ADDRESS = '0x1001'; + static const tags = { + 'XMR': "$MONERO_STD_ADDRESS,$MONERO_SUB_ADDRESS", + 'BTC': '0x1003', + 'LTC': '0x1019' + }; + + static Future> fetchYatAddress( + String emojiId, + String ticker, + ) async { + final tagQuery = tags[ticker.toUpperCase()]; + if (tagQuery == null) return const []; + + final uri = Uri.parse( + lookupEmojiUrl(emojiId.replaceAll(' ', '')), + ).replace(queryParameters: {'tags': tagQuery}); + + try { + final response = await ProxyWrapper().get(clearnetUri: uri); + final body = json.decode(response.body) as Map; + + final res = body['result']; + if (res == null) return const []; + + final records = []; + + if (res is Map) { + res.forEach((tag, data) { + if (data is Map) { + records.add(YatRecord.fromJson(data, tag.toString())); + } + }); + } + + else if (res is List) { + for (final item in res) { + if (item is Map) { + final tag = item['tag']?.toString() ?? ''; + records.add(YatRecord.fromJson(item, tag)); + } + } + } + + return records; + } catch (_) { + return const []; + } + } +} \ No newline at end of file diff --git a/lib/store/yat/yat_store.dart b/lib/core/address_resolver/yat/yat_store.dart similarity index 100% rename from lib/store/yat/yat_store.dart rename to lib/core/address_resolver/yat/yat_store.dart diff --git a/lib/core/address_resolver/zano/zano_alias_address_provider.dart b/lib/core/address_resolver/zano/zano_alias_address_provider.dart new file mode 100644 index 000000000..e02856405 --- /dev/null +++ b/lib/core/address_resolver/zano/zano_alias_address_provider.dart @@ -0,0 +1,75 @@ +import 'dart:convert'; + +import 'package:cake_wallet/core/address_resolver/address_lookup_provider.dart'; +import 'package:cake_wallet/core/address_resolver/address_sources.dart'; +import 'package:cake_wallet/core/address_resolver/parsed_address.dart'; +import 'package:cake_wallet/store/settings_store.dart'; +import 'package:cw_core/crypto_currency.dart'; +import 'package:cw_core/utils/print_verbose.dart'; +import 'package:cw_core/utils/proxy_wrapper.dart'; +import 'package:cw_core/wallet_base.dart'; + +class ZanoAliasAddressProvider extends AddressLookupProvider { + + @override + AddressSource get source => AddressSource.zanoAlias; + + @override + List get supportedCurrencies => [CryptoCurrency.zano]; + + @override + bool isEnabled(SettingsStore settingsStore) => settingsStore.lookupsZanoAlias; + + @override + bool canHandle(String query) => query.startsWith('@'); // zano handle example: @username + + static const _zanoUrl = 'http://37.27.100.59:10500/json_rpc'; + @override + Future> resolve({ + required String query, + required List currencies, + required WalletBase wallet, + }) async { + try { + if (!currencies.contains(CryptoCurrency.zano)) return []; + + final formattedName = query.replaceFirst("@", ""); + if (formattedName.isEmpty) return []; + + final zanoAddress = await fetchZanoAliasAddress(formattedName); + if (zanoAddress == null || zanoAddress.isEmpty) return []; + + return [ + ParsedAddress( + parsedAddressByCurrencyMap: {CryptoCurrency.zano: zanoAddress}, + addressSource: AddressSource.zanoAlias, + handle: query, + ), + ]; + } catch (e) { + printV('[address resolver] Error resolving Zano Alias: $e'); + return []; + } + } + + static Future fetchZanoAliasAddress(String alias) async { + try { + final uri = Uri.parse(_zanoUrl); + final response = await ProxyWrapper().post( + clearnetUri: uri, + body: json.encode({ + 'id': 0, + 'jsonrpc': '2.0', + 'method': 'get_alias_details', + 'params': {'alias': alias}, + }), + ); + + final jsonParsed = json.decode(response.body) as Map; + return jsonParsed['result']?['alias_details']?['address'] as String?; + } catch (e) { + printV('[address resolver] Zano Alias error: $e'); + return null; + } + } +} diff --git a/lib/core/address_resolver/zcash/zcash_address_provider.dart b/lib/core/address_resolver/zcash/zcash_address_provider.dart new file mode 100644 index 000000000..5da25fdd4 --- /dev/null +++ b/lib/core/address_resolver/zcash/zcash_address_provider.dart @@ -0,0 +1,49 @@ +import 'package:cake_wallet/core/address_resolver/address_lookup_provider.dart'; +import 'package:cake_wallet/core/address_resolver/address_sources.dart'; +import 'package:cake_wallet/core/address_resolver/parsed_address.dart'; +import 'package:cake_wallet/core/address_resolver/zcash/zcash_names_record.dart'; +import 'package:cake_wallet/store/settings_store.dart'; +import 'package:cw_core/crypto_currency.dart'; +import 'package:cw_core/utils/print_verbose.dart'; +import 'package:cw_core/wallet_base.dart'; + +class ZcashNameAddressProvider extends AddressLookupProvider { + @override + AddressSource get source => AddressSource.zcashName; + + @override + List get supportedCurrencies => [CryptoCurrency.zec]; + + @override + bool isEnabled(SettingsStore settingsStore) => settingsStore.lookupsZcashNames; + + @override + bool canHandle(String query) { + final lowerText = query.toLowerCase(); + return lowerText.endsWith(".zec") || lowerText.endsWith(".zcash"); + } // Zcash handle example: name.zec + + @override + Future> resolve({ + required String query, + required List currencies, + required WalletBase wallet, + }) async { + try { + final address = await ZcashNamesRecord.fetchZcashNamesAddress(query); + if (address == null || address.isEmpty) return []; + + return [ + ParsedAddress( + parsedAddressByCurrencyMap: {CryptoCurrency.zec: address}, + profileName: query, + addressSource: AddressSource.zcashName, + handle: query, + ), + ]; + } catch (e) { + printV('[address resolver] Error resolving Zcash Name: $e'); + return []; + } + } +} diff --git a/lib/core/address_resolver/zcash/zcash_me_address_provider.dart b/lib/core/address_resolver/zcash/zcash_me_address_provider.dart new file mode 100644 index 000000000..6dd0968d9 --- /dev/null +++ b/lib/core/address_resolver/zcash/zcash_me_address_provider.dart @@ -0,0 +1,74 @@ +import 'package:cake_wallet/core/address_resolver/address_lookup_provider.dart'; +import 'package:cake_wallet/core/address_resolver/address_sources.dart'; +import 'package:cake_wallet/core/address_resolver/parsed_address.dart'; +import 'package:cake_wallet/store/settings_store.dart'; +import 'package:cw_core/crypto_currency.dart'; +import 'package:cw_core/utils/print_verbose.dart'; +import 'package:cw_core/utils/proxy_wrapper.dart'; +import 'package:cw_core/wallet_base.dart'; + +class ZcashMeAddressProvider extends AddressLookupProvider { + @override + AddressSource get source => AddressSource.zcashAddress; + + @override + List get supportedCurrencies => [CryptoCurrency.zec]; + + @override + bool isEnabled(SettingsStore settingsStore) => settingsStore.lookupsZcashNames; + + @override + bool canHandle(String query) { + final value = query.trim().toLowerCase(); + return value.startsWith('zcash.me/') && value.split('/').last.isNotEmpty; + } // zcash.me profile example: zcash.me/username + + @override + Future> resolve({ + required String query, + required List currencies, + required WalletBase wallet, + }) async { + try { + final handle = query.trim().split('/').last; + if (handle.isEmpty) return []; + + final address = await _fetchZcashAddress(handle); + if (address == null || address.isEmpty) return []; + + return [ + ParsedAddress( + parsedAddressByCurrencyMap: {CryptoCurrency.zec: address}, + profileName: handle, + addressSource: AddressSource.zcashAddress, + handle: query, + ), + ]; + } catch (e) { + printV('[address resolver] Error resolving zcash.me address: $e'); + return []; + } + } + + Future _fetchZcashAddress(String handle) async { + final url = Uri.parse('https://zcash.me/$handle'); + + try { + final response = await ProxyWrapper().get(clearnetUri: url); + + if (response.statusCode == 200) { + final addressRegex = RegExp( + r'(t1[0-9A-Za-z]{33}|t3[0-9A-Za-z]{33}|zs[a-z0-9]{76}|u1[a-z0-9]{1,300})', + caseSensitive: true, + ); + + final match = addressRegex.firstMatch(response.body); + return match?.group(0); + } + } catch (e) { + printV('[address resolver] Error fetching zcash.me profile: $e'); + } + + return null; + } +} diff --git a/lib/entities/zcash_names_record.dart b/lib/core/address_resolver/zcash/zcash_names_record.dart similarity index 100% rename from lib/entities/zcash_names_record.dart rename to lib/core/address_resolver/zcash/zcash_names_record.dart diff --git a/lib/core/address_validator.dart b/lib/core/address_validator.dart index 0d44bad67..d6934542e 100644 --- a/lib/core/address_validator.dart +++ b/lib/core/address_validator.dart @@ -12,29 +12,54 @@ const AFTER_REGEX = '(\$|\\s)'; class AddressValidator extends TextValidator { AddressValidator({required CryptoCurrency type, bool isTestnet = false}) : super( - errorMessage: S.current.error_text_address, - useAdditionalValidation: [CryptoCurrency.btc, CryptoCurrency.ltc].contains(type) - ? (String txt) { - final RegExp lightningInvoiceRegex = RegExp( - r'^(lightning:)?(lnbc|lntb|lnbs|lnbcrt|lnurl)[a-z0-9]+$', - caseSensitive: false); - if (lightningInvoiceRegex.hasMatch(txt)) return true; + errorMessage: S.current.error_text_address, + useAdditionalValidation: [CryptoCurrency.btc, CryptoCurrency.ltc].contains(type) + ? (String txt) { + final RegExp lightningInvoiceRegex = RegExp( + r'^(lightning:)?(lnbc|lntb|lnbs|lnbcrt|lnurl)[a-z0-9]+$', + caseSensitive: false); + if (lightningInvoiceRegex.hasMatch(txt)) return true; - return BitcoinAddressUtils.validateAddress( - address: txt, - network: type == CryptoCurrency.btc - ? isTestnet - ? BitcoinNetwork.testnet - : BitcoinNetwork.mainnet - : LitecoinNetwork.mainnet, - ); - } - : type == CryptoCurrency.zano - ? zano?.validateAddress - : null, - pattern: getPattern(type, isTestnet: isTestnet), - length: getLength(type), - ); + return BitcoinAddressUtils.validateAddress( + address: txt, + network: type == CryptoCurrency.btc + ? isTestnet + ? BitcoinNetwork.testnet + : BitcoinNetwork.mainnet + : LitecoinNetwork.mainnet, + ); + } + : type == CryptoCurrency.zano + ? zano?.validateAddress + : null, + pattern: getPattern(type, isTestnet: isTestnet), + length: getLength(type), + ); + + static const List reliableValidateCurrencies = [ + CryptoCurrency.xmr, + CryptoCurrency.btc, + CryptoCurrency.ltc, + CryptoCurrency.bch, + CryptoCurrency.trx, + CryptoCurrency.nano, + CryptoCurrency.sol, + CryptoCurrency.zano, + CryptoCurrency.ada, + CryptoCurrency.xrp, + CryptoCurrency.xhv, + CryptoCurrency.zaddr, + CryptoCurrency.zec, + CryptoCurrency.dcr, + CryptoCurrency.rvn, + CryptoCurrency.near, + CryptoCurrency.rune, + CryptoCurrency.scrt, + CryptoCurrency.stx, + CryptoCurrency.kmd, + CryptoCurrency.doge, + CryptoCurrency.btcln, + ]; static String getPattern(CryptoCurrency type, {bool isTestnet = false}) { var pattern = ""; @@ -146,13 +171,13 @@ class AddressValidator extends TextValidator { pattern = '[0-9a-zA-Z.]+'; case CryptoCurrency.zec: pattern = - '(?:' - 't1[0-9A-Za-z]{33}' - '|t3[0-9A-Za-z]{33}' - '|zs[a-z0-9]{76}' - '|zxviews[a-z0-9]{278}' - '|u1[a-z0-9]{1,300}' - ')'; + '(?:' + 't1[0-9A-Za-z]{33}' + '|t3[0-9A-Za-z]{33}' + '|zs[a-z0-9]{76}' + '|zxviews[a-z0-9]{278}' + '|u1[a-z0-9]{1,300}' + ')'; case CryptoCurrency.dcr: pattern = '(D|T|S)[ksecS]([0-9a-zA-Z])+'; case CryptoCurrency.rvn: @@ -344,7 +369,7 @@ class AddressValidator extends TextValidator { '|([0-9a-zA-Z]{106})'; case CryptoCurrency.btc: pattern = - '${P2pkhAddress.regex.pattern}|${P2shAddress.regex.pattern}|${P2wpkhAddress.regex.pattern}|${P2trAddress.regex.pattern}|${P2wshAddress.regex.pattern}|${SilentPaymentAddress.regex.pattern}'; + '${P2pkhAddress.regex.pattern}|${P2shAddress.regex.pattern}|${P2wpkhAddress.regex.pattern}|${P2trAddress.regex.pattern}|${P2wshAddress.regex.pattern}|${SilentPaymentAddress.regex.pattern}'; case CryptoCurrency.ltc: pattern = '([^0-9a-zA-Z]|^)^L[a-zA-Z0-9]{26,33}([^0-9a-zA-Z]|\$)' '|([^0-9a-zA-Z]|^)[LM][a-km-zA-HJ-NP-Z1-9]{26,33}([^0-9a-zA-Z]|\$)' @@ -394,4 +419,4 @@ class AddressValidator extends TextValidator { return pattern != null ? "($pattern)" : null; } -} +} \ No newline at end of file diff --git a/lib/core/yat_service.dart b/lib/core/yat_service.dart deleted file mode 100644 index 84aac1626..000000000 --- a/lib/core/yat_service.dart +++ /dev/null @@ -1,55 +0,0 @@ -import 'dart:convert'; - -import 'package:cake_wallet/entities/yat_record.dart'; -import 'package:cw_core/utils/proxy_wrapper.dart'; - -class YatService { - static bool isDevMode = false; - - static String get apiUrl => - YatService.isDevMode ? YatService.apiDevUrl : YatService.apiReleaseUrl; - static const apiReleaseUrl = "https://a.y.at"; - static const apiDevUrl = 'https://a.yat.fyi'; - - static String lookupEmojiUrl(String emojiId) => - "$apiUrl/emoji_id/$emojiId/payment"; - - static const String MONERO_SUB_ADDRESS = '0x1002'; - static const String MONERO_STD_ADDRESS = '0x1001'; - static const tags = { - 'XMR': "$MONERO_STD_ADDRESS,$MONERO_SUB_ADDRESS", - 'BTC': '0x1003', - 'LTC': '0x1019' - }; - - Future> fetchYatAddress(String emojiId, String ticker) async { - final formattedTicker = ticker.toUpperCase(); - final formattedEmojiId = emojiId.replaceAll(' ', ''); - final tag = tags[formattedTicker]; - final uri = Uri.parse(lookupEmojiUrl(formattedEmojiId)).replace( - queryParameters: { - "tags": tag - }); - final yatRecords = []; - - try { - final response = await ProxyWrapper().get(clearnetUri: uri); - - final resBody = json.decode(response.body) as Map; - final results = resBody["result"] as Map; - // Favour a subaddress over a standard address. - final yatRecord = ( - results[MONERO_SUB_ADDRESS] ?? - results[MONERO_STD_ADDRESS] ?? - results[tag]) as Map; - - if (yatRecord.isNotEmpty) { - yatRecords.add(YatRecord.fromJson(yatRecord)); - } - - return yatRecords; - } catch (_) { - return yatRecords; - } - } -} diff --git a/lib/di.dart b/lib/di.dart index 821f51130..bf2b64443 100644 --- a/lib/di.dart +++ b/lib/di.dart @@ -6,6 +6,9 @@ import 'package:cake_wallet/anonpay/anonpay_invoice_info.dart'; import 'package:cake_wallet/anypay/anypay_api.dart'; import 'package:cake_wallet/bitcoin/bitcoin.dart'; import 'package:cake_wallet/bitcoin_cash/bitcoin_cash.dart'; +import 'package:cake_wallet/core/address_resolver/address_resolver_service.dart'; +import 'package:cake_wallet/core/address_resolver/yat/yat_service.dart'; +import 'package:cake_wallet/core/address_resolver/yat/yat_store.dart'; import 'package:cake_wallet/entities/bitcoin_amount_display_mode.dart'; import 'package:cake_wallet/evm/evm.dart'; import 'package:cake_wallet/buy/dfx/dfx_buy_provider.dart'; @@ -29,7 +32,6 @@ import 'package:cake_wallet/core/totp_request_details.dart'; import 'package:cake_wallet/core/trade_monitor.dart'; import 'package:cake_wallet/core/wallet_creation_service.dart'; import 'package:cake_wallet/core/wallet_loading_service.dart'; -import 'package:cake_wallet/core/yat_service.dart'; import 'package:cake_wallet/decred/decred.dart'; import 'package:cake_wallet/entities/biometric_auth.dart'; import 'package:cake_wallet/entities/bridge_transfer.dart'; @@ -37,7 +39,6 @@ import 'package:cake_wallet/entities/contact.dart'; import 'package:cake_wallet/entities/contact_record.dart'; import 'package:cake_wallet/entities/exchange_api_mode.dart'; import 'package:cake_wallet/entities/hardware_wallet/require_hardware_wallet_connection.dart'; -import 'package:cake_wallet/entities/parse_address_from_domain.dart'; import 'package:cake_wallet/entities/preferences_key.dart'; import 'package:cake_wallet/entities/qr_view_data.dart'; import 'package:cake_wallet/entities/template.dart'; @@ -198,7 +199,6 @@ import 'package:cake_wallet/store/settings_store.dart'; import 'package:cake_wallet/store/templates/exchange_template_store.dart'; import 'package:cake_wallet/store/templates/send_template_store.dart'; import 'package:cake_wallet/store/wallet_list_store.dart'; -import 'package:cake_wallet/store/yat/yat_store.dart'; import 'package:cake_wallet/themes/core/theme_store.dart'; import 'package:cake_wallet/tron/tron.dart'; import 'package:cake_wallet/utils/device_info.dart'; @@ -899,6 +899,7 @@ Future setup({ getIt.get(), getIt.get(), getIt.get(), + getIt.get(), getIt.get(), getIt.get(), _transactionDescriptionBox, @@ -1100,7 +1101,10 @@ Future setup({ AnimatedURPage(getIt.get(), urQr: urQr)); getIt.registerFactoryParam( - (ContactRecord? contact, _) => ContactViewModel(_contactSource, contact: contact)); + (ContactRecord? contact, _) => ContactViewModel(_contactSource, + getIt.get(), + getIt.get(), + contact: contact)); getIt.registerFactoryParam( (CryptoCurrency? cur, _) => @@ -1249,7 +1253,10 @@ Future setup({ getIt.registerFactoryParam( (PaymentRequest? paymentRequest, __) { - return ExchangePage(getIt.get(), getIt.get(), paymentRequest); + return ExchangePage(getIt.get(), + getIt.get(), + getIt.get(), + paymentRequest); }); getIt.registerFactoryParam( @@ -1257,6 +1264,7 @@ Future setup({ return NewSwapPage( getIt.get(), getIt.get(), + getIt.get(), paymentRequest, walletSwitcherViewModel: getIt.get(), initialCurrency: initialCurrency, @@ -1494,7 +1502,7 @@ Future setup({ getIt.registerFactory(() => BuySellViewModel(getIt.get())); - getIt.registerFactory(() => BuySellPage(getIt.get())); + getIt.registerFactory(() => BuySellPage(getIt.get(), getIt.get())); getIt.registerFactoryParam, void>((List args, _) { final items = args.first as List; @@ -1592,10 +1600,9 @@ Future setup({ getIt.registerFactory(() => YatService()); - getIt.registerFactory(() => AddressResolver( - yatService: getIt.get(), - wallet: getIt.get().wallet!, - settingsStore: getIt.get())); + getIt.registerFactory( + () => AddressResolverService(settingsStore: getIt.get()), + ); getIt.registerFactoryParam( (QrViewData viewData, _) => FullscreenQRPage(qrViewData: viewData)); diff --git a/lib/entities/ens_record.dart b/lib/entities/ens_record.dart deleted file mode 100644 index 239b5f45c..000000000 --- a/lib/entities/ens_record.dart +++ /dev/null @@ -1,54 +0,0 @@ -import 'package:cake_wallet/evm/evm.dart'; -import 'package:cake_wallet/reactions/wallet_connect.dart'; -import 'package:cw_core/utils/proxy_wrapper.dart'; -import 'package:cw_core/utils/print_verbose.dart'; -import 'package:cw_core/wallet_base.dart'; -import 'package:cw_core/wallet_type.dart'; -import 'package:ens_dart/ens_dart.dart'; -import 'package:web3dart/web3dart.dart'; - -class EnsRecord { - static Future fetchEnsAddress(String name, {WalletBase? wallet}) async { - Web3Client? _client; - - if (wallet != null && (isEVMCompatibleChain(wallet.type))) { - _client = evm!.getWeb3Client(wallet); - } - - if (_client == null) { - late final client = ProxyWrapper().getHttpIOClient(); - - _client = Web3Client("https://ethereum-rpc.publicnode.com", client); - } - - try { - final ens = Ens(client: _client); - - if (wallet != null) { - switch (wallet.type) { - case WalletType.monero: - return await ens.withName(name).getCoinAddress(CoinType.XMR); - case WalletType.bitcoin: - return await ens.withName(name).getCoinAddress(CoinType.BTC); - case WalletType.litecoin: - return await ens.withName(name).getCoinAddress(CoinType.LTC); - case WalletType.haven: - return await ens.withName(name).getCoinAddress(CoinType.XHV); - case WalletType.ethereum: - case WalletType.polygon: - case WalletType.base: - case WalletType.arbitrum: - case WalletType.bsc: - default: - return (await ens.withName(name).getAddress()).hex; - } - } - - final addr = await ens.withName(name).getAddress(); - return addr.hex; - } catch (e) { - printV(e); - return ""; - } - } -} diff --git a/lib/entities/fio_address_provider.dart b/lib/entities/fio_address_provider.dart deleted file mode 100644 index dcfd79c96..000000000 --- a/lib/entities/fio_address_provider.dart +++ /dev/null @@ -1,65 +0,0 @@ -import 'dart:convert'; - -import 'package:cw_core/utils/proxy_wrapper.dart'; - -class FioAddressProvider { - static const apiAuthority = 'fio.blockpane.com'; - static const availCheck = '/v1/chain/avail_check'; - static const getAddress = '/v1/chain/get_pub_address'; - - static Future checkAvail(String fioAddress) async { - bool isFioRegistered = false; - final headers = {'Content-Type': 'application/json'}; - final body = {"fio_name": fioAddress}; - - final uri = Uri.https(apiAuthority, availCheck); - final response = await ProxyWrapper().post( - clearnetUri: uri, - headers: headers, - body: json.encode(body), - ); - - if (response.statusCode != 200) { - return isFioRegistered; - } - - - final responseJSON = json.decode(response.body) as Map; - isFioRegistered = responseJSON['is_registered'] as int == 1; - - return isFioRegistered; - } - - static Future getPubAddress(String fioAddress, String token) async { - final headers = {'Content-Type': 'application/json'}; - final body = { - "fio_address": fioAddress, - "chain_code": token.toUpperCase(), - "token_code": token.toUpperCase(), - }; - - final uri = Uri.https(apiAuthority, getAddress); - final response = await ProxyWrapper().post( - clearnetUri: uri, - headers: headers, - body: json.encode(body), - ); - - - if (response.statusCode == 400) { - final responseJSON = json.decode(response.body) as Map; - final error = responseJSON['error'] as String; - final message = responseJSON['message'] as String; - throw Exception('${error}\n$message'); - } - - if (response.statusCode != 200) { - throw Exception('Unexpected response http status: ${response.statusCode}'); - } - - final responseJSON = json.decode(response.body) as Map; - final String pubAddress = responseJSON['public_address'] as String; - - return pubAddress; - } -} diff --git a/lib/entities/openalias_record.dart b/lib/entities/openalias_record.dart deleted file mode 100644 index b2af2e53d..000000000 --- a/lib/entities/openalias_record.dart +++ /dev/null @@ -1,89 +0,0 @@ -import 'package:basic_utils/basic_utils.dart'; -import 'package:cw_core/utils/print_verbose.dart'; - -class OpenaliasRecord { - OpenaliasRecord({ - required this.address, - required this.name, - required this.description, - }); - - final String name; - final String address; - final String description; - - static String formatDomainName(String name) { - String formattedName = name; - - if (name.contains("@")) { - formattedName = name.replaceAll("@", "."); - } - - return formattedName; - } - - static Future?> lookupOpenAliasRecord(String name) async { - try { - final txtRecord = await DnsUtils.lookupRecord(name, RRecordType.TXT, dnssec: true); - - return txtRecord; - } catch (e) { - printV("${e.toString()}"); - return null; - } - } - - static OpenaliasRecord fetchAddressAndName({ - required String formattedName, - required String ticker, - required List txtRecord, - }) { - String address = ''; - String name = formattedName; - String note = ''; - - for (RRecord element in txtRecord) { - String record = element.data; - - if (record.contains("oa1:$ticker") && record.contains("recipient_address")) { - record = record.replaceAll('\"', ""); - - final dataList = record.split(";"); - - address = dataList - .where((item) => (item.contains("recipient_address"))) - .toString() - .replaceAll("oa1:$ticker recipient_address=", "") - .replaceAll("(", "") - .replaceAll(")", "") - .trim(); - - final recipientName = dataList - .where((item) => (item.contains("recipient_name"))) - .toString() - .replaceAll("(", "") - .replaceAll(")", "") - .trim(); - - if (recipientName.isNotEmpty) { - name = recipientName.replaceAll("recipient_name=", ""); - } - - final description = dataList - .where((item) => (item.contains("tx_description"))) - .toString() - .replaceAll("(", "") - .replaceAll(")", "") - .trim(); - - if (description.isNotEmpty) { - note = description.replaceAll("tx_description=", ""); - } - - break; - } - - } - return OpenaliasRecord(address: address, name: name, description: note); -} -} diff --git a/lib/entities/parse_address_from_domain.dart b/lib/entities/parse_address_from_domain.dart deleted file mode 100644 index 7a6a47bc8..000000000 --- a/lib/entities/parse_address_from_domain.dart +++ /dev/null @@ -1,549 +0,0 @@ -import 'package:cake_wallet/core/address_validator.dart'; -import 'package:cake_wallet/core/yat_service.dart'; -import 'package:cake_wallet/entities/emoji_string_extension.dart'; -import 'package:cake_wallet/entities/ens_record.dart'; -import 'package:cake_wallet/entities/lnurlpay_record.dart'; -import 'package:cake_wallet/entities/fio_address_provider.dart'; -import 'package:cake_wallet/entities/openalias_record.dart'; -import 'package:cake_wallet/entities/parsed_address.dart'; -import 'package:cake_wallet/entities/unstoppable_domain_address.dart'; -import 'package:cake_wallet/entities/wellknown_record.dart'; -import 'package:cake_wallet/entities/zano_alias.dart'; -import 'package:cake_wallet/entities/zcash_names_record.dart'; -import 'package:cake_wallet/exchange/provider/thorchain_exchange.provider.dart'; -import 'package:cake_wallet/mastodon/mastodon_api.dart'; -import 'package:cake_wallet/nostr/nostr_api.dart'; -import 'package:cake_wallet/store/settings_store.dart'; -import 'package:cake_wallet/twitter/twitter_api.dart'; -import 'package:cw_core/crypto_currency.dart'; -import 'package:cw_core/utils/print_verbose.dart'; -import 'package:cw_core/utils/proxy_wrapper.dart'; -import 'package:cw_core/wallet_base.dart'; -import 'package:cw_core/wallet_type.dart'; -import 'package:flutter/cupertino.dart'; - -import 'bip_353_record.dart'; - -class AddressResolver { - AddressResolver({required this.yatService, required this.wallet, required this.settingsStore}) - : walletType = wallet.type; - - final YatService yatService; - final WalletType walletType; - final WalletBase wallet; - final SettingsStore settingsStore; - - static const unstoppableDomains = [ - "888", - "academy", - "agency", - "altimist", - "anime", - "austin", - "bald", - "bay", - "benji", - "bet", - "binanceus", - "bitcoin", - "bitget", - "bitscrunch", - "blockchain", - "boomer", - "boston", - "ca", - "caw", - "cc", - "chat", - "chomp", - "clay", - "club", - "co", - "com", - "company", - "crypto", - "dao", - "design", - "dfz", - "digital", - "doga", - "donut", - "dream", - "email", - "emir", - "eth", - "ethermail", - "family", - "farms", - "finance", - "fun", - "fyi", - "games", - "global", - "go", - "group", - "guru", - "hi", - "hockey", - "host", - "info", - "io", - "klever", - "kresus", - "kryptic", - "lfg", - "life", - "live", - "llc", - "ltc", - "ltd", - "manga", - "me", - "media", - "metropolis", - "miami", - "miku", - "money", - "moon", - "mumu", - "net", - "network", - "news", - "nft", - "npc", - "onchain", - "online", - "org", - "podcast", - "pog", - "polygon", - "base", - "arbitrum" - "press", - "privacy", - "pro", - "propykeys", - "pudgy", - "pw", - "quantum", - "rad", - "raiin", - "retardio", - "rip", - "rocks", - "secret", - "services", - "site", - "smobler", - "social", - "solutions", - "space", - "stepn", - "store", - "studio", - "systems", - "tball", - "tea", - "team", - "tech", - "technology", - "today", - "tribe", - "u", - "ubu", - "uno", - "unstoppable", - "vip", - "wallet", - "website", - "wif", - "wifi", - "witg", - "work", - "world", - "wrkx", - "wtf", - "x", - "xmr", - "xyz", - "zil", - "zone", - "pizza" - ]; - - static String? extractAddressByType( - {required String raw, - required CryptoCurrency type, - bool requireSurroundingWhitespaces = true}) { - var addressPattern = AddressValidator.getAddressFromStringPattern(type); - - if (addressPattern == null) { - return null; - } - - if (requireSurroundingWhitespaces) - addressPattern = "$BEFORE_REGEX$addressPattern$AFTER_REGEX"; - - final match = RegExp(addressPattern, multiLine: true).firstMatch(raw); - return match?.group(0)?.replaceAllMapped(RegExp('[^0-9a-zA-Z]|bitcoincash:|nano_|ban_'), - (Match match) { - String group = match.group(0)!; - if (group.startsWith('bitcoincash:') || - group.startsWith('nano_') || - group.startsWith('ban_')) { - return group; - } - return ''; - }); - } - - static String extractUnstoppableDomain(String raw) { - // sort by length to avoid matching shorter tld instead of longer - final domains = List.from(unstoppableDomains)..sort((a, b) => b.length.compareTo(a.length)); - for (final tld in domains) { - final pattern = RegExp(r'([A-Za-z0-9-]+)\.' + RegExp.escape(tld), caseSensitive: false); - final match = pattern.firstMatch(raw); - if (match != null) return match.group(0)!; - } - return ''; - } - - bool isEmailFormat(String address) { - final RegExp emailRegex = RegExp( - r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$', - caseSensitive: false, - ); - return emailRegex.hasMatch(address); - } - - String _stripHtmlTags(String value) { - return value - .replaceAll(RegExp(r'', caseSensitive: false), '\n') - .replaceAll(RegExp(r'', caseSensitive: false), '\n') - .replaceAll(RegExp(r'<[^>]*>'), '') - .trim(); - } - - Future resolve(BuildContext context, String text, CryptoCurrency currency) async { - final ticker = currency.title; - try { - if (text.startsWith("zcash.me")) { - final parts = text.split("/"); - final handle = parts.last; - if (parts.length == 2 && handle.isNotEmpty) { - final extractZcashAddress = await _fetchZcashAddress(handle); - if (extractZcashAddress != null) { - return ParsedAddress.zcashAddress( - address: extractZcashAddress, - name: handle, - ); - } - } - } - // twitter handle example: @username - if (text.startsWith('@') && !text.substring(1).contains('@')) { - if (currency == CryptoCurrency.zano && settingsStore.lookupsZanoAlias) { - final formattedName = text.substring(1); - final zanoAddress = await ZanoAlias.fetchZanoAliasAddress(formattedName); - if (zanoAddress != null && zanoAddress.isNotEmpty) { - return ParsedAddress.zanoAddress( - address: zanoAddress, - name: text, - ); - } - } - if (settingsStore.lookupsTwitter) { - final formattedName = text.substring(1); - final twitterUser = await TwitterApi.lookupUserByName(userName: formattedName); - final addressFromBio = extractAddressByType( - raw: twitterUser.description, - type: CryptoCurrency.fromString(ticker, walletCurrency: wallet.currency)); - if (addressFromBio != null && addressFromBio.isNotEmpty) { - return ParsedAddress.fetchTwitterAddress( - address: addressFromBio, - name: text, - profileImageUrl: twitterUser.profileImageUrl, - profileName: twitterUser.name); - } else { - final domain = extractUnstoppableDomain(twitterUser.description); - if (domain.isNotEmpty) { - final parsedAddressFromDomain = await resolve(context, domain, currency); - if (parsedAddressFromDomain.addresses.isNotEmpty && - parsedAddressFromDomain.addresses.first != domain) { - return ParsedAddress( - addresses: parsedAddressFromDomain.addresses, - name: text, - profileImageUrl: twitterUser.profileImageUrl, - profileName: twitterUser.name, - parseFrom: ParseFrom.twitter, - ); - } - - } - - } - - final addressFromLocation = extractAddressByType( - raw: twitterUser.location, - type: CryptoCurrency.fromString(ticker, - walletCurrency: wallet.currency), requireSurroundingWhitespaces: false); - if (addressFromLocation != null && addressFromLocation.isNotEmpty) { - return ParsedAddress.fetchTwitterAddress( - address: addressFromLocation, - name: text, - profileImageUrl: twitterUser.profileImageUrl, - profileName: twitterUser.name); - } else { - final domain = extractUnstoppableDomain(twitterUser.location); - if (domain.isNotEmpty) { - final parsedAddressFromDomain = await resolve(context, domain, currency); - if (parsedAddressFromDomain.addresses.isNotEmpty && - parsedAddressFromDomain.addresses.first != domain) { - return ParsedAddress( - addresses: parsedAddressFromDomain.addresses, - name: text, - profileImageUrl: twitterUser.profileImageUrl, - profileName: twitterUser.name, - parseFrom: ParseFrom.twitter, - ); - } - - } - - } - - final pinnedTweet = twitterUser.pinnedTweet?.text; - if (pinnedTweet != null) { - final addressFromPinnedTweet = extractAddressByType( - raw: pinnedTweet, - type: CryptoCurrency.fromString(ticker, walletCurrency: wallet.currency), - requireSurroundingWhitespaces: true); - if (addressFromPinnedTweet != null) { - return ParsedAddress.fetchTwitterAddress( - address: addressFromPinnedTweet, - name: text, - profileImageUrl: twitterUser.profileImageUrl, - profileName: twitterUser.name); - } else { - final domain = extractUnstoppableDomain(pinnedTweet); - if (domain.isNotEmpty) { - final parsedAddressFromDomain = await resolve(context, domain, currency); - if (parsedAddressFromDomain.addresses.isNotEmpty && - parsedAddressFromDomain.addresses.first != domain) { - return ParsedAddress( - addresses: parsedAddressFromDomain.addresses, - name: text, - profileImageUrl: twitterUser.profileImageUrl, - profileName: twitterUser.name, - parseFrom: ParseFrom.twitter, - ); - } - } - } - } - } - } - - // Mastodon example: @username@hostname.xxx - if (text.startsWith('@') && text.contains('@', 1) && text.contains('.', 1)) { - if (settingsStore.lookupsMastodon) { - final subText = text.substring(1); - final hostNameIndex = subText.indexOf('@'); - final hostName = subText.substring(hostNameIndex + 1); - final userName = subText.substring(0, hostNameIndex); - - final mastodonUser = - await MastodonAPI.lookupUserByUserName(userName: userName, apiHost: hostName); - - if (mastodonUser != null) { - final mastodonNote = _stripHtmlTags(mastodonUser.note); - String? addressFromBio = extractAddressByType(raw: mastodonNote, type: currency, requireSurroundingWhitespaces: true); - - if (addressFromBio != null && addressFromBio.isNotEmpty) { - return ParsedAddress.fetchMastodonAddress( - address: addressFromBio, - name: text, - profileImageUrl: mastodonUser.profileImageUrl, - profileName: mastodonUser.username); - } else { - final pinnedPosts = - await MastodonAPI.getPinnedPosts(userId: mastodonUser.id, apiHost: hostName); - - if (pinnedPosts.isNotEmpty) { - final userPinnedPostsText = pinnedPosts.map((item) => _stripHtmlTags(item.content)).join('\n'); - String? addressFromPinnedPost = - extractAddressByType(raw: userPinnedPostsText, type: currency, - requireSurroundingWhitespaces: true); - - if (addressFromPinnedPost != null && addressFromPinnedPost.isNotEmpty) { - return ParsedAddress.fetchMastodonAddress( - address: addressFromPinnedPost, - name: text, - profileImageUrl: mastodonUser.profileImageUrl, - profileName: mastodonUser.username); - } - } - } - } - } - } - - // .well-known scheme: - if (text.contains('.') && text.contains('@')) { - if (settingsStore.lookupsWellKnown) { - final record = - await WellKnownRecord.fetchAddressAndName(formattedName: text, currency: currency); - if (record != null) { - return ParsedAddress.fetchWellKnownAddress(address: record.address, name: text); - } - } - - if (walletType == WalletType.bitcoin) { - final record = - await LNUrlPayRecord.fetchAddressAndName(formattedName: text, currency: currency); - if (record != null) { - return ParsedAddress.fetchLNUrlPayAddress(address: record.address, name: text); - } - } - } - - if (!text.startsWith('@') && text.contains('@') && !text.contains('.')) { - final bool isFioRegistered = await FioAddressProvider.checkAvail(text); - if (isFioRegistered) { - final address = await FioAddressProvider.getPubAddress(text, ticker); - return ParsedAddress.fetchFioAddress(address: address, name: text); - } - } - if (text.hasOnlyEmojis) { - if (settingsStore.lookupsYatService) { - if (walletType != WalletType.haven) { - final addresses = await yatService.fetchYatAddress(text, ticker); - return ParsedAddress.fetchEmojiAddress(addresses: addresses, name: text); - } - } - } - - final isNormalAddress = extractAddressByType(raw: text, type: currency)?.isNotEmpty ?? false; - - if (text.length <= 30 && !isNormalAddress) { - final thorChainAddress = await ThorChainExchangeProvider.lookupAddressByName(text); - if (thorChainAddress != null && thorChainAddress.isNotEmpty) { - String? address = - thorChainAddress[ticker] ?? (ticker == 'RUNE' ? thorChainAddress['THOR'] : null); - if (address != null) { - return ParsedAddress.thorChainAddress(address: address, name: text); - } - } - } - - final formattedName = OpenaliasRecord.formatDomainName(text); - final domainParts = formattedName.split('.'); - final name = domainParts.last; - - if (domainParts.length <= 1 || domainParts.first.isEmpty || name.isEmpty) { - return ParsedAddress(addresses: [text]); - } - - if (unstoppableDomains.any((domain) => name.trim() == domain)) { - if (settingsStore.lookupsUnstoppableDomains) { - final address = await fetchUnstoppableDomainAddress(text, ticker); - if (address.isNotEmpty) { - return ParsedAddress.fetchUnstoppableDomainAddress(address: address, name: text); - } - } - } - - final bip353AddressMap = await Bip353Record.fetchUriByCryptoCurrency(text, ticker); - - if (bip353AddressMap != null && bip353AddressMap.isNotEmpty) { - final chosenAddress = await Bip353Record.pickBip353AddressChoice(context, text, bip353AddressMap); - if (chosenAddress != null) { - try { - final dnsProof = await Bip353Record.fetchDnsProof(text); - return ParsedAddress.fetchBip353AddressAddress(address: chosenAddress, name: text, dnsProof: dnsProof); - } catch (e) { - printV('Bip353Record.fetchBip353AddressAddress error: $e'); - return ParsedAddress.fetchBip353AddressAddress(address: chosenAddress, name: text); - } - } - } - - final lowerText = text.toLowerCase(); - if (lowerText.endsWith(".zec") || lowerText.endsWith(".zcash")) { - if (settingsStore.lookupsZcashNames) { - final address = await ZcashNamesRecord.fetchZcashNamesAddress(text); - if (address != null && address.isNotEmpty) { - return ParsedAddress.zcashNameAddress(address: address, name: text); - } - } - } - - if (text.endsWith(".eth")) { - if (settingsStore.lookupsENS) { - final address = await EnsRecord.fetchEnsAddress(text, wallet: wallet); - if (address.isNotEmpty && address != "0x0000000000000000000000000000000000000000") { - return ParsedAddress.fetchEnsAddress(name: text, address: address); - } - } - } - - if (formattedName.contains(".")) { - if (settingsStore.lookupsOpenAlias) { - final txtRecord = await OpenaliasRecord.lookupOpenAliasRecord(formattedName); - - if (txtRecord != null) { - final record = await OpenaliasRecord.fetchAddressAndName( - formattedName: formattedName, ticker: ticker.toLowerCase(), txtRecord: txtRecord); - return ParsedAddress.fetchOpenAliasAddress(record: record, name: text); - } - } - } - if (isEmailFormat(text)) { - final nostrProfile = await NostrProfileHandler.queryProfile(context, text); - if (nostrProfile?.relays != null) { - final nostrUserData = - await NostrProfileHandler.processRelays(context, nostrProfile!, text); - - if (nostrUserData != null) { - String? addressFromBio = extractAddressByType(raw: nostrUserData.about, type: currency); - if (addressFromBio != null && addressFromBio.isNotEmpty) { - return ParsedAddress.nostrAddress( - address: addressFromBio, - name: text, - profileImageUrl: nostrUserData.picture, - profileName: nostrUserData.name); - } - } - } - } - } catch (e) { - printV(e.toString()); - } - - return ParsedAddress(addresses: [text]); - } - - Future _fetchZcashAddress(String handle) async { - final url = Uri.parse('https://zcash.me/$handle'); - - try { - final response = await ProxyWrapper().get(clearnetUri: url); - - if (response.statusCode == 200) { - final addressRegex = RegExp( - r'(t1[0-9A-Za-z]{33}|t3[0-9A-Za-z]{33}|zs[a-z0-9]{76}|u1[a-z0-9]{1,300})', - caseSensitive: true, - ); - - final match = addressRegex.firstMatch(response.body); - - if (match != null) { - return match.group(0); - } - } - } catch (e) { - printV('Error fetching zcash.me profile: $e'); - } - return null; - } -} diff --git a/lib/entities/parsed_address.dart b/lib/entities/parsed_address.dart deleted file mode 100644 index 28bdf704f..000000000 --- a/lib/entities/parsed_address.dart +++ /dev/null @@ -1,212 +0,0 @@ -import 'package:cake_wallet/entities/openalias_record.dart'; -import 'package:cake_wallet/entities/yat_record.dart'; - -enum ParseFrom { - unstoppableDomains, - openAlias, - yatRecord, - fio, - notParsed, - twitter, - ens, - contact, - mastodon, - nostr, - thorChain, - wellKnown, - zanoAlias, - zcashAddress, - zcashName, - bip353, - lnurlpay, -} - -class ParsedAddress { - ParsedAddress({ - required this.addresses, - this.name = '', - this.description = '', - this.profileImageUrl = '', - this.profileName = '', - this.parseFrom = ParseFrom.notParsed, - this.bip353DnsProof, - }); - - factory ParsedAddress.fetchEmojiAddress({ - List? addresses, - required String name, - }) { - if (addresses?.isEmpty ?? true) { - return ParsedAddress(addresses: [name], parseFrom: ParseFrom.yatRecord); - } - return ParsedAddress( - addresses: addresses!.map((e) => e.address).toList(), - name: name, - parseFrom: ParseFrom.yatRecord, - ); - } - - factory ParsedAddress.fetchUnstoppableDomainAddress({ - String? address, - required String name, - }) { - if (address?.isEmpty ?? true) { - return ParsedAddress(addresses: [name]); - } - return ParsedAddress( - addresses: [address!], - name: name, - parseFrom: ParseFrom.unstoppableDomains, - ); - } - - factory ParsedAddress.fetchBip353AddressAddress ({ - required String address, - required String name, - String? dnsProof, - }) { - return ParsedAddress( - addresses: [address], - name: name, - parseFrom: ParseFrom.bip353, - bip353DnsProof: dnsProof, - ); - } - - factory ParsedAddress.fetchOpenAliasAddress( - {required OpenaliasRecord record, required String name}) { - if (record.address.isEmpty) { - return ParsedAddress(addresses: [name]); - } - return ParsedAddress( - addresses: [record.address], - name: record.name, - description: record.description, - parseFrom: ParseFrom.openAlias, - ); - } - - factory ParsedAddress.fetchFioAddress({required String address, required String name}) { - return ParsedAddress( - addresses: [address], - name: name, - parseFrom: ParseFrom.fio, - ); - } - - factory ParsedAddress.fetchTwitterAddress( - {required String address, - required String name, - required String profileImageUrl, - required String profileName, - String? description}) { - return ParsedAddress( - addresses: [address], - name: name, - description: description ?? '', - profileImageUrl: profileImageUrl, - profileName: profileName, - parseFrom: ParseFrom.twitter, - ); - } - - factory ParsedAddress.fetchMastodonAddress( - {required String address, - required String name, - required String profileImageUrl, - required String profileName}) { - return ParsedAddress( - addresses: [address], - name: name, - parseFrom: ParseFrom.mastodon, - profileImageUrl: profileImageUrl, - profileName: profileName, - ); - } - - factory ParsedAddress.fetchContactAddress({required String address, required String name}) { - return ParsedAddress( - addresses: [address], - name: name, - parseFrom: ParseFrom.contact, - ); - } - - factory ParsedAddress.fetchEnsAddress({required String address, required String name}) { - return ParsedAddress( - addresses: [address], - name: name, - parseFrom: ParseFrom.ens, - ); - } - - factory ParsedAddress.nostrAddress( - {required String address, - required String name, - required String profileImageUrl, - required String profileName}) { - return ParsedAddress( - addresses: [address], - name: name, - parseFrom: ParseFrom.nostr, - profileImageUrl: profileImageUrl, - profileName: profileName, - ); - } - - factory ParsedAddress.thorChainAddress({required String address, required String name}) { - return ParsedAddress( - addresses: [address], - name: name, - parseFrom: ParseFrom.thorChain, - ); - } - - factory ParsedAddress.zanoAddress({required String address, required String name}) { - return ParsedAddress( - addresses: [address], - name: name, - parseFrom: ParseFrom.zanoAlias, - ); - } - - factory ParsedAddress.zcashAddress({required String address, required String name}) { - return ParsedAddress( - addresses: [address], - name: name, - parseFrom: ParseFrom.zcashAddress, - ); - } - - factory ParsedAddress.zcashNameAddress({required String address, required String name}) { - return ParsedAddress( - addresses: [address], - name: name, - parseFrom: ParseFrom.zcashName, - ); - } - - factory ParsedAddress.fetchWellKnownAddress({required String address, required String name}) { - return ParsedAddress( - addresses: [address], - name: name, - parseFrom: ParseFrom.wellKnown, - ); - } - - factory ParsedAddress.fetchLNUrlPayAddress({required String address, required String name}) { - return ParsedAddress( - addresses: [address], - name: name, - parseFrom: ParseFrom.lnurlpay, - ); - } - - final List addresses; - final String name; - final String description; - final String profileImageUrl; - final String profileName; - final ParseFrom parseFrom; - final String? bip353DnsProof; -} diff --git a/lib/entities/preferences_key.dart b/lib/entities/preferences_key.dart index be90ed6ab..c052633a0 100644 --- a/lib/entities/preferences_key.dart +++ b/lib/entities/preferences_key.dart @@ -102,7 +102,13 @@ class PreferencesKey { static const lookupsOpenAlias = 'looks_up_open_alias'; static const lookupsENS = 'looks_up_ens'; static const lookupsZcashNames = 'looks_up_zcash_names'; + static const lookupsZcashAddress = 'looks_up_zcash_address'; static const lookupsWellKnown = 'looks_up_well_known'; + static const lookupsFio = 'looks_up_fio'; + static const lookupsNostr = 'looks_up_nostr'; + static const lookupsThorChain = 'looks_up_thor_chain'; + static const lookupsBip353 = 'looks_up_bip353'; + static const lookupsLNUrl = 'looks_up_lnurl'; static const useBlinkProtection = 'use_blink_protection'; static const usePayjoin = 'use_payjoin'; static const showPayjoinCard = 'show_payjoin_card'; diff --git a/lib/entities/unstoppable_domain_address.dart b/lib/entities/unstoppable_domain_address.dart deleted file mode 100644 index cf6eb8945..000000000 --- a/lib/entities/unstoppable_domain_address.dart +++ /dev/null @@ -1,30 +0,0 @@ -import 'dart:convert'; - -import 'package:cw_core/utils/print_verbose.dart'; -import 'package:cw_core/utils/proxy_wrapper.dart'; - -Future fetchUnstoppableDomainAddress(String domain, String ticker) async { - var address = ''; - - try { - final uri = Uri.parse("https://api.unstoppabledomains.com/profile/public/${Uri.encodeQueryComponent(domain)}?fields=records"); - final response = await ProxyWrapper().get(clearnetUri: uri); - - final jsonParsed = json.decode(response.body) as Map; - if (jsonParsed["records"] == null) { - throw Exception(".records response from $uri is empty"); - }; - final records = jsonParsed["records"] as Map; - final key = "crypto.${ticker.toUpperCase()}.address"; - if (records[key] == null) { - throw Exception(".records.${key} response from $uri is empty"); - } - - return records[key] as String? ?? ''; - } catch (e) { - printV('Unstoppable domain error: ${e.toString()}'); - address = ''; - } - - return address; -} \ No newline at end of file diff --git a/lib/entities/wellknown_record.dart b/lib/entities/wellknown_record.dart deleted file mode 100644 index 6dd94440b..000000000 --- a/lib/entities/wellknown_record.dart +++ /dev/null @@ -1,93 +0,0 @@ -import 'dart:convert'; - -import 'package:cw_core/crypto_currency.dart'; -import 'package:cw_core/utils/print_verbose.dart'; -import 'package:cw_core/utils/proxy_wrapper.dart'; - -class WellKnownRecord { - WellKnownRecord({ - required this.address, - required this.name, - }); - - final String name; - final String address; - - static Future checkWellKnownUsername(String username, CryptoCurrency currency) async { - String jsonLocation = ""; - switch (currency) { - case CryptoCurrency.nano: - jsonLocation = "nano-currency"; - break; - // TODO: add other currencies - default: - return null; - } - - // split the string by the @ symbol: - try { - final List splitStrs = username.split("@"); - String name = splitStrs.first.toLowerCase(); - final String domain = splitStrs.last; - - if (splitStrs.length == 3) { - // for username like @alice@domain.org instead of alice@domain.org - name = splitStrs[1]; - } - - if (name.isEmpty) { - name = "_"; - } - - // lookup domain/.well-known/nano-currency.json and check if it has a nano address: - final response = await ProxyWrapper().get( - clearnetUri: Uri.parse("https://$domain/.well-known/$jsonLocation.json?names=$name"), - headers: {"Accept": "application/json"}, - ); - - if (response.statusCode != 200) { - return null; - } - - final Map decoded = json.decode(response.body) as Map; - - // Access the first element in the names array and retrieve its address - final List names = decoded["names"] as List; - for (final dynamic item in names) { - if (item["name"].toLowerCase() == name) { - return item["address"] as String; - } - } - } catch (e) { - printV("error checking well-known username: $e"); - } - return null; - } - - static String formatDomainName(String name) { - String formattedName = name; - - if (name.contains("@")) { - formattedName = name.replaceAll("@", "."); - } - - return formattedName; - } - - static Future fetchAddressAndName({ - required String formattedName, - required CryptoCurrency currency, - }) async { - String name = formattedName; - - printV("formattedName: $formattedName"); - - final address = await checkWellKnownUsername(formattedName, currency); - - if (address == null) { - return null; - } - - return WellKnownRecord(address: address, name: name); - } -} diff --git a/lib/entities/yat_record.dart b/lib/entities/yat_record.dart deleted file mode 100644 index 40e677a85..000000000 --- a/lib/entities/yat_record.dart +++ /dev/null @@ -1,13 +0,0 @@ -class YatRecord { - YatRecord({ - required this.category, - required this.address, - }); - - YatRecord.fromJson(Map json) - : address = json['address'] as String, - category = json['category'] as String; - - String category; - String address; -} diff --git a/lib/entities/zano_alias.dart b/lib/entities/zano_alias.dart deleted file mode 100644 index 3d1c74969..000000000 --- a/lib/entities/zano_alias.dart +++ /dev/null @@ -1,29 +0,0 @@ -import 'dart:convert'; - -import 'package:cw_core/utils/print_verbose.dart'; -import 'package:cw_core/utils/proxy_wrapper.dart'; - -class ZanoAlias { - static Future fetchZanoAliasAddress(String alias) async { - try { - final uri = Uri.parse("http://37.27.100.59:10500/json_rpc"); - final response = await ProxyWrapper().post( - clearnetUri: uri, - body: json.encode({ - "id": 0, - "jsonrpc": "2.0", - "method": "get_alias_details", - "params": {"alias": alias} - }), - ); - - final jsonParsed = json.decode(response.body) as Map; - - return jsonParsed['result']['alias_details']['address'] as String?; - } catch (e) { - printV('Zano Alias error: ${e.toString()}'); - } - - return null; - } -} diff --git a/lib/new-ui/pages/send_page.dart b/lib/new-ui/pages/send_page.dart index aa7cff5af..a993afbb2 100644 --- a/lib/new-ui/pages/send_page.dart +++ b/lib/new-ui/pages/send_page.dart @@ -50,6 +50,7 @@ import 'package:cake_wallet/utils/payment_request.dart'; import 'package:cake_wallet/utils/show_pop_up.dart'; import 'package:cake_wallet/view_model/payment/payment_view_model.dart'; import 'package:cake_wallet/view_model/send/output.dart'; +import 'package:cake_wallet/core/address_resolver/parsed_address.dart'; import 'package:cake_wallet/view_model/send/send_view_model.dart'; import 'package:cake_wallet/view_model/send/send_view_model_state.dart'; import 'package:cake_wallet/view_model/wallet_switcher_view_model.dart'; @@ -263,15 +264,34 @@ class _NewSendPageState extends State { _addressFocusNode.addListener(() async { if (!_addressFocusNode.hasFocus && _addressControllers[_selectedOutput].text.isNotEmpty) { final output = widget.sendViewModel.outputs[_selectedOutput]; - output.fetchParsedAddress(context).then((val) { - if (_addressControllers[_selectedOutput].text != output.extractedAddress) { - _addressControllers[_selectedOutput].text = output.extractedAddress; - } - }); + await _resolveAddressForOutput(output); } }); } + Future _resolveAddressForOutput(Output output) async { + final result = await widget.sendViewModel.resolveAddressForOutput(output); + if (result == null) return; + + final confirmed = await showParsedAddressConfirmationAlert(context, result); + + if (confirmed) { + output.applyAddressLookupResult(result); + } else { + output.resetParsedAddress(); + return; + } + + final outputIndex = widget.sendViewModel.outputs.indexOf(output); + if (outputIndex >= 0 && + outputIndex < _addressControllers.length && + output.extractedAddress.isNotEmpty && + _addressControllers[outputIndex].text != output.extractedAddress) { + _addressControllers[outputIndex].text = output.extractedAddress; + } + } + + @override Widget build(BuildContext context) { return Observer(builder: (_) { @@ -377,7 +397,7 @@ class _NewSendPageState extends State { focusNode: _addressFocusNode, onURIScanned: (uri) async { output.resetParsedAddress(); - await output.fetchParsedAddress(context); + await _resolveAddressForOutput(output); // Process the payment through the new flow await _handlePaymentFlow( @@ -404,7 +424,7 @@ class _NewSendPageState extends State { _justHandledPasteButton = true; try { output.resetParsedAddress(); - await output.fetchParsedAddress(context); + await _resolveAddressForOutput(output); final address = output.isParsedAddress ? output.extractedAddress @@ -1429,3 +1449,28 @@ class SendHelpPage extends StatelessWidget { ); } } + +Future showParsedAddressConfirmationAlert( + BuildContext context, + ParsedAddress parsedAddress, +) async { + final confirmed = await showPopUp( + context: context, + builder: (BuildContext context) { + return AlertWithOneAction( + alertTitle: S.of(context).address_detected, + headerTitleText: parsedAddress.profileName.isEmpty ? null : parsedAddress.profileName, + headerImageProfileUrl: parsedAddress.profileImageUrl.isEmpty + ? parsedAddress.addressSource.iconPath + : parsedAddress.profileImageUrl, + alertContent: S.of(context).extracted_address_content( + '${parsedAddress.handle} (${parsedAddress.addressSource.label})', + ), + buttonText: S.of(context).ok, + buttonAction: () => Navigator.of(context).pop(true), + ); + }, + ); + + return confirmed ?? false; +} \ No newline at end of file diff --git a/lib/new-ui/pages/swap_page.dart b/lib/new-ui/pages/swap_page.dart index 1e897743e..2120a9a1f 100644 --- a/lib/new-ui/pages/swap_page.dart +++ b/lib/new-ui/pages/swap_page.dart @@ -1,8 +1,8 @@ +import 'package:cake_wallet/core/address_resolver/address_resolver_service.dart'; import 'package:cake_wallet/core/address_validator.dart'; import 'package:cake_wallet/core/amount_validator.dart'; import 'package:cake_wallet/core/auth_service.dart'; import 'package:cake_wallet/di.dart'; -import 'package:cake_wallet/entities/parse_address_from_domain.dart'; import 'package:cake_wallet/entities/qr_scanner.dart'; import 'package:cake_wallet/exchange/exchange_trade_state.dart'; import 'package:cake_wallet/exchange/provider/chainflip_exchange_provider.dart'; @@ -23,7 +23,6 @@ import 'package:cake_wallet/new-ui/widgets/currency_picker/currency_picker_args. import 'package:cake_wallet/new-ui/widgets/currency_picker/currency_picker_sheet.dart'; import 'package:cake_wallet/new-ui/widgets/swap_page/swap_options_page.dart'; import 'package:cake_wallet/src/screens/exchange/widgets/present_provider_picker.dart'; -import 'package:cake_wallet/src/screens/send/widgets/extract_address_from_parsed.dart'; import 'package:cake_wallet/src/widgets/alert_with_one_action.dart'; import 'package:cake_wallet/src/widgets/alert_with_two_actions.dart'; import 'package:cake_wallet/src/widgets/cake_image_widget.dart'; @@ -52,7 +51,7 @@ import 'package:mobx/mobx.dart'; import 'package:modal_bottom_sheet/modal_bottom_sheet.dart'; class NewSwapPage extends StatefulWidget { - NewSwapPage(this.exchangeViewModel, this.authService, this.initialPaymentRequest, + NewSwapPage(this.exchangeViewModel, this.authService,this.adrResService, this.initialPaymentRequest, {required this.walletSwitcherViewModel, CryptoCurrency? initialCurrency}) { depositWalletName = exchangeViewModel.depositCurrency == CryptoCurrency.xmr ? exchangeViewModel.wallet.name @@ -68,6 +67,7 @@ class NewSwapPage extends StatefulWidget { final ExchangeViewModel exchangeViewModel; final WalletSwitcherViewModel walletSwitcherViewModel; final AuthService authService; + final AddressResolverService adrResService; final PaymentRequest? initialPaymentRequest; late final String? depositWalletName; late final String? receiveWalletName; @@ -423,8 +423,11 @@ class _NewSwapPageState extends State { Future fetchParsedAddress( BuildContext context, String domain, CryptoCurrency currency) async { printV("$domain"); - final parsedAddress = await getIt.get().resolve(context, domain, currency); - return extractAddressFromParsed(context, parsedAddress); + final parsedAddress = await widget.adrResService.resolve( + query: domain, + wallet: widget.exchangeViewModel.wallet, + currency: currency); + return parsedAddress.isNotEmpty ? parsedAddress.first.parsedAddressByCurrencyMap[currency] ?? '' : ''; } void _showFeeAlert(BuildContext context) async { diff --git a/lib/new-ui/viewmodels/lightning_username/lightning_username_bloc.dart b/lib/new-ui/viewmodels/lightning_username/lightning_username_bloc.dart index 435154aa4..e284ba34a 100644 --- a/lib/new-ui/viewmodels/lightning_username/lightning_username_bloc.dart +++ b/lib/new-ui/viewmodels/lightning_username/lightning_username_bloc.dart @@ -1,7 +1,7 @@ import 'package:bloc/bloc.dart'; import 'package:bloc_concurrency/bloc_concurrency.dart'; import 'package:cake_wallet/bitcoin/bitcoin.dart'; -import 'package:cake_wallet/entities/lnurlpay_record.dart'; +import 'package:cake_wallet/core/address_resolver/lnurl_pay/lnurlpay_record.dart'; import 'package:cake_wallet/generated/i18n.dart'; import 'package:cw_core/crypto_currency.dart'; import 'package:cw_core/wallet_base.dart'; diff --git a/lib/new-ui/widgets/send_page/send_confirm_sheet.dart b/lib/new-ui/widgets/send_page/send_confirm_sheet.dart index e573de4f2..5234c9c3d 100644 --- a/lib/new-ui/widgets/send_page/send_confirm_sheet.dart +++ b/lib/new-ui/widgets/send_page/send_confirm_sheet.dart @@ -227,13 +227,14 @@ class SendTransactionDetails extends StatelessWidget { decimals: 2) : sendViewModel.pendingTransactionFeeFiatAmountFormatted; - final showAddress = !sendViewModel.outputs.any((e) => - RegExp(AddressValidator.bolt11InvoiceMatcher).hasMatch(e.address.toLowerCase()) || - RegExp(AddressValidator.lnurlMatcher).hasMatch(e.address.toLowerCase()) || - (e.isParsedAddress && - e.parsedAddress.addresses.isNotEmpty && - RegExp(AddressValidator.lnurlMatcher) - .hasMatch(e.parsedAddress.addresses.firstOrNull?.toLowerCase() ?? ""))); + final showAddress = !sendViewModel.outputs.any((e) => + RegExp(AddressValidator.bolt11InvoiceMatcher).hasMatch(e.address.toLowerCase()) || + RegExp(AddressValidator.lnurlMatcher).hasMatch(e.address.toLowerCase()) || + (e.isParsedAddress && + e.parsedAddress.parsedAddressByCurrencyMap[sendViewModel.selectedCryptoCurrency] != null && + e.parsedAddress.parsedAddressByCurrencyMap[sendViewModel.selectedCryptoCurrency]!.isNotEmpty && + RegExp(AddressValidator.lnurlMatcher) + .hasMatch(e.parsedAddress.parsedAddressByCurrencyMap[sendViewModel.selectedCryptoCurrency]!.toLowerCase()))); final outputs = sendViewModel.outputs; diff --git a/lib/nostr/nostr_api.dart b/lib/nostr/nostr_api.dart deleted file mode 100644 index 8d9164e57..000000000 --- a/lib/nostr/nostr_api.dart +++ /dev/null @@ -1,150 +0,0 @@ -import 'dart:convert'; - -import 'package:cake_wallet/generated/i18n.dart'; -import 'package:cake_wallet/nostr/nostr_user.dart'; -import 'package:cake_wallet/src/widgets/alert_with_one_action.dart'; -import 'package:cake_wallet/src/widgets/picker.dart'; -import 'package:cake_wallet/utils/show_pop_up.dart'; -import 'package:cw_core/utils/print_verbose.dart'; -import 'package:flutter/material.dart'; -import 'package:nostr_tools/nostr_tools.dart'; - -class NostrProfileHandler { - static final relayToDomainMap = { - 'relay.snort.social': 'snort.social', - }; - - static Nip05 _nip05 = Nip05(); - - static Future queryProfile(BuildContext context, String nip05Address) async { - var profile = await _nip05.queryProfile(nip05Address); - if (profile?.pubkey != null) { - if (profile?.relays?.isNotEmpty == true) { - return profile; - } else { - await _showErrorDialog(context, S.of(context).no_relays, S.of(context).no_relays_message); - } - } - return null; - } - - static Future processRelays( - BuildContext context, - ProfilePointer profile, - String nip05Address, - ) async { - String userDomain = _extractDomain(nip05Address); - const int metaData = 0; - - for (String relayUrl in profile.relays ?? []) { - final relayDomain = _getDomainFromRelayUrl(relayUrl); - final formattedRelayDomain = relayToDomainMap[relayDomain] ?? relayDomain; - if (formattedRelayDomain == userDomain) { - final userDomainData = await _fetchInfoFromRelay(relayUrl, profile.pubkey, [metaData]); - if (userDomainData != null) { - return userDomainData; - } - } - } - await _showErrorDialog(context, S.of(context).no_relays, S.of(context).no_relay_on_domain); - - String? chosenRelayUrl = - await _showRelayChoiceDialog(context, profile.relays ?? []); - if (chosenRelayUrl != null) { - final userData = await _fetchInfoFromRelay(chosenRelayUrl, profile.pubkey, [metaData]); - if (userData != null) { - return userData; - } - } - - return null; - } - - static Future _fetchInfoFromRelay( - String relayUrl, String userPubKey, List kinds) async { - try { - final relay = RelayApi(relayUrl: relayUrl); - final stream = await relay.connect(); - - relay.sub([ - Filter( - kinds: kinds, - authors: [userPubKey], - ) - ]); - - await for (var message in stream) { - if (message.type == 'EVENT') { - final event = message.message as Event; - - final eventContent = json.decode(event.content) as Map; - - final userMetadata = UserMetadata.fromJson(eventContent); - relay.close(); - return userMetadata; - } - } - - relay.close(); - return null; - } catch (e) { - printV('[!] Error with relay $relayUrl: $e'); - return null; - } - } - - static Future _showErrorDialog( - BuildContext context, String title, String errorMessage) async { - if (context.mounted) { - await showPopUp( - context: context, - builder: (BuildContext dialogContext) { - return AlertWithOneAction( - alertTitle: title, - alertContent: errorMessage, - buttonText: S.of(dialogContext).ok, - buttonAction: () => Navigator.of(dialogContext).pop(), - ); - }, - ); - } - } - - static String _extractDomain(String nip05Address) { - var parts = nip05Address.split('@'); - return parts.length == 2 ? parts[1] : ''; - } - - static String _getDomainFromRelayUrl(String relayUrl) { - try { - var uri = Uri.parse(relayUrl); - return uri.host; - } catch (e) { - printV('Error parsing URL: $e'); - return ''; - } - } - - static Future _showRelayChoiceDialog( - BuildContext context, - List relays, - ) async { - String? selectedRelay; - - if (context.mounted) { - await showPopUp( - context: context, - builder: (BuildContext dialogContext) { - return Picker( - selectedAtIndex: 0, - title: S.of(dialogContext).choose_relay, - items: relays, - onItemSelected: (String relay) => selectedRelay = relay, - ); - }, - ); - } - - return selectedRelay; - } -} diff --git a/lib/src/screens/buy/buy_sell_page.dart b/lib/src/screens/buy/buy_sell_page.dart index 73c238b57..2db9a9568 100644 --- a/lib/src/screens/buy/buy_sell_page.dart +++ b/lib/src/screens/buy/buy_sell_page.dart @@ -1,7 +1,7 @@ import 'package:cake_wallet/buy/sell_buy_states.dart'; +import 'package:cake_wallet/core/address_resolver/address_resolver_service.dart'; import 'package:cake_wallet/core/address_validator.dart'; import 'package:cake_wallet/di.dart'; -import 'package:cake_wallet/entities/parse_address_from_domain.dart'; import 'package:cake_wallet/generated/i18n.dart'; import 'package:cake_wallet/new-ui/widgets/currency_picker/currency_picker_args.dart'; import 'package:cake_wallet/new-ui/widgets/currency_picker/currency_picker_sheet.dart'; @@ -16,7 +16,6 @@ import 'package:cake_wallet/src/widgets/primary_button.dart'; import 'package:cake_wallet/src/widgets/provider_optoin_tile.dart'; import 'package:cake_wallet/src/widgets/scrollable_with_bottom_section.dart'; import 'package:cake_wallet/src/widgets/trail_button.dart'; -import 'package:cake_wallet/src/screens/send/widgets/extract_address_from_parsed.dart'; import 'package:cake_wallet/utils/responsive_layout_util.dart'; import 'package:cake_wallet/view_model/buy/buy_sell_view_model.dart'; import 'package:cw_core/crypto_currency.dart'; @@ -28,9 +27,10 @@ import 'package:keyboard_actions/keyboard_actions.dart'; import 'package:mobx/mobx.dart'; class BuySellPage extends BasePage { - BuySellPage(this.buySellViewModel); + BuySellPage(this.buySellViewModel, this._resolver); final BuySellViewModel buySellViewModel; + final AddressResolverService _resolver; final cryptoCurrencyKey = GlobalKey(); final fiatCurrencyKey = GlobalKey(); final _formKey = GlobalKey(); @@ -300,7 +300,7 @@ class BuySellPage extends BasePage { }); reaction((_) => buySellViewModel.cryptoCurrencyAddress, (String address) { - if (cryptoAddressController != address) { + if (cryptoAddressController.text != address) { cryptoCurrencyKey.currentState!.addressController.text = address; } }); @@ -320,8 +320,10 @@ class BuySellPage extends BasePage { _cryptoAddressFocus.addListener(() async { if (!_cryptoAddressFocus.hasFocus && cryptoAddressController.text.isNotEmpty) { final domain = cryptoAddressController.text; - buySellViewModel.cryptoCurrencyAddress = await fetchParsedAddress( - context, domain, buySellViewModel.cryptoCurrency); + final parsed = await fetchParsedAddress(context, domain, buySellViewModel.cryptoCurrency); + if (parsed.isNotEmpty) { + buySellViewModel.cryptoCurrencyAddress = parsed; + } } }); @@ -410,8 +412,23 @@ class BuySellPage extends BasePage { currencyButtonColor: Colors.transparent, addressButtonsColor: Theme.of(context).colorScheme.surfaceContainerHighest, borderColor: Theme.of(context).colorScheme.outlineVariant, - onPushPasteButton: (context) async {}, - onPushAddressBookButton: (context) async {}, + onPushPasteButton: (context) async { + final domain = cryptoCurrencyKey.currentState!.addressController.text; + final parsed = + await fetchParsedAddress(context, domain, buySellViewModel.cryptoCurrency); + if (parsed.isNotEmpty) { + buySellViewModel.cryptoCurrencyAddress = parsed; + } + }, + + onPushAddressBookButton: (context) async { + final domain = cryptoCurrencyKey.currentState!.addressController.text; + final parsed = + await fetchParsedAddress(context, domain, buySellViewModel.cryptoCurrency); + if (parsed.isNotEmpty) { + buySellViewModel.cryptoCurrencyAddress = parsed; + } + }, fillColor: buySellViewModel.isBuyAction ? Theme.of(context).colorScheme.surfaceContainer : Theme.of(context).colorScheme.surfaceContainerLow, @@ -537,9 +554,10 @@ class BuySellPage extends BasePage { String domain, CryptoCurrency currency, ) async { - final parsedAddress = - await getIt.get().resolve(context, domain, currency); - final address = await extractAddressFromParsed(context, parsedAddress); - return address; + final parsedAddresses = await _resolver.resolve( + query: domain, wallet: buySellViewModel.wallet, currency: currency); + return parsedAddresses.isNotEmpty + ? parsedAddresses.first.parsedAddressByCurrencyMap[currency] ?? '' + : ''; } } diff --git a/lib/src/screens/contact/contact_list_page.dart b/lib/src/screens/contact/contact_list_page.dart index 07cb5625e..e60140bd5 100644 --- a/lib/src/screens/contact/contact_list_page.dart +++ b/lib/src/screens/contact/contact_list_page.dart @@ -340,6 +340,7 @@ class _ContactListBodyState extends State { updateFunction: widget.contactListViewModel.reorderAccordingToContactList, canReorder: widget.contactListViewModel.isEditable, shrinkWrap: true, + padding: const EdgeInsets.only(bottom: 250), itemBuilder: (context, index) { final contact = contacts[index]; final contactContent = diff --git a/lib/src/screens/contact/contact_page.dart b/lib/src/screens/contact/contact_page.dart index d5ca5fc0e..b4ea348f2 100644 --- a/lib/src/screens/contact/contact_page.dart +++ b/lib/src/screens/contact/contact_page.dart @@ -1,3 +1,4 @@ +import 'package:cake_wallet/core/address_resolver/parsed_address.dart'; import 'package:cake_wallet/core/address_validator.dart'; import 'package:cake_wallet/new-ui/widgets/send_page/send_address_input.dart'; import 'package:cake_wallet/utils/show_pop_up.dart'; @@ -27,7 +28,15 @@ class ContactPage extends BasePage { _nameController.text = contactViewModel.name; _addressController.text = contactViewModel.address; _nameController.addListener(() => contactViewModel.name = _nameController.text); - _addressController.addListener(() => contactViewModel.address = _addressController.text); + _addressController.addListener(() { + final address = _addressController.text; + + if (address != contactViewModel.address) { + contactViewModel.displayName = ''; + } + + contactViewModel.address = address; + }); autorun((_) => _currencyTypeController.text = "${contactViewModel.currency?.toString() ?? ""} ${contactViewModel.currency?.tag != null ? "(${contactViewModel.currency?.tag})" : ""}"); } @@ -98,7 +107,8 @@ class ContactPage extends BasePage { focusNode: addressFocusNode, displayName: contactViewModel.displayName, addressController: _addressController, - onEditingComplete: ()=> contactViewModel.extractParsedAddress(context), + onEditingComplete: () {}, + onPushPasteButton: (context) => _extractParsedAddress(context), validator: AddressValidator(type: contactViewModel.currency!), selectedCurrency: contactViewModel.currency!, ); @@ -129,8 +139,7 @@ class ContactPage extends BasePage { builder: (_) => PrimaryButton( onPressed: () async { FocusScope.of(context).unfocus(); - - await contactViewModel.extractParsedAddress(context); + _extractParsedAddress(context); if (_formKey.currentState != null && !_formKey.currentState!.validate()) { return; @@ -150,6 +159,16 @@ class ContactPage extends BasePage { ); } + Future _extractParsedAddress(BuildContext context) async { + final parsedAddress = await contactViewModel.extractParsedAddress(context); + if (parsedAddress == null) return; + + final confirmed = await showParsedAddressConfirmationAlert(context, parsedAddress); + if (!confirmed) return; + + contactViewModel.applyParsedAddress(parsedAddress); + } + void _presentCurrencyPicker(BuildContext context) { showPopUp( builder: (_) => CurrencyPicker( @@ -203,4 +222,29 @@ class ContactPage extends BasePage { } }); } + + Future showParsedAddressConfirmationAlert( + BuildContext context, + ParsedAddress parsedAddress, + ) async { + final confirmed = await showPopUp( + context: context, + builder: (BuildContext context) { + return AlertWithOneAction( + alertTitle: S.of(context).address_detected, + headerTitleText: parsedAddress.profileName.isEmpty ? null : parsedAddress.profileName, + headerImageProfileUrl: parsedAddress.profileImageUrl.isEmpty + ? parsedAddress.addressSource.iconPath + : parsedAddress.profileImageUrl, + alertContent: S.of(context).extracted_address_content( + '${parsedAddress.handle} (${parsedAddress.addressSource.label})', + ), + buttonText: S.of(context).ok, + buttonAction: () => Navigator.of(context).pop(true), + ); + }, + ); + + return confirmed ?? false; + } } diff --git a/lib/src/screens/exchange/exchange_page.dart b/lib/src/screens/exchange/exchange_page.dart index 40fa6c5cc..1dc2fe94f 100644 --- a/lib/src/screens/exchange/exchange_page.dart +++ b/lib/src/screens/exchange/exchange_page.dart @@ -1,3 +1,4 @@ +import 'package:cake_wallet/core/address_resolver/address_resolver_service.dart'; import 'package:cake_wallet/exchange/exchange_provider_description.dart'; import 'package:cake_wallet/exchange/provider/chainflip_exchange_provider.dart'; import 'package:cake_wallet/exchange/provider/thorchain_exchange.provider.dart'; @@ -11,8 +12,6 @@ import 'package:cake_wallet/utils/payment_request.dart'; import 'package:cake_wallet/utils/responsive_layout_util.dart'; import 'package:cw_core/sync_status.dart'; import 'package:cw_core/utils/print_verbose.dart'; -import 'package:cake_wallet/entities/parse_address_from_domain.dart'; -import 'package:cake_wallet/src/screens/send/widgets/extract_address_from_parsed.dart'; import 'package:cake_wallet/src/widgets/standard_checkbox.dart'; import 'package:flutter/material.dart'; import 'package:flutter_mobx/flutter_mobx.dart'; @@ -41,7 +40,7 @@ import 'package:cake_wallet/src/screens/exchange/widgets/present_provider_picker import 'package:cake_wallet/src/screens/dashboard/widgets/sync_indicator_icon.dart'; class ExchangePage extends BasePage { - ExchangePage(this.exchangeViewModel, this.authService, this.initialPaymentRequest) { + ExchangePage(this.exchangeViewModel, this.authService, this.adrResService, this.initialPaymentRequest) { depositWalletName = exchangeViewModel.depositCurrency == CryptoCurrency.xmr ? exchangeViewModel.wallet.name : null; @@ -52,6 +51,7 @@ class ExchangePage extends BasePage { final ExchangeViewModel exchangeViewModel; final AuthService authService; + final AddressResolverService adrResService; final PaymentRequest? initialPaymentRequest; final depositKey = GlobalKey(); final receiveKey = GlobalKey(); @@ -655,8 +655,8 @@ class ExchangePage extends BasePage { Future fetchParsedAddress( BuildContext context, String domain, CryptoCurrency currency) async { - final parsedAddress = await getIt.get().resolve(context, domain, currency); - return extractAddressFromParsed(context, parsedAddress); + final parsedAddresses = await adrResService.resolve(query: domain, wallet:exchangeViewModel.wallet, currency: currency); + return parsedAddresses.isNotEmpty ? parsedAddresses.first.parsedAddressByCurrencyMap[currency] ?? '' : ''; } void _showFeeAlert(BuildContext context) async { diff --git a/lib/src/screens/send/send_page.dart b/lib/src/screens/send/send_page.dart index d886963b2..a72aa79ae 100644 --- a/lib/src/screens/send/send_page.dart +++ b/lib/src/screens/send/send_page.dart @@ -780,7 +780,6 @@ class SendPage extends BasePage { } output.resetParsedAddress(); - await output.fetchParsedAddress(context); } Output _defineCurrentOutput() { diff --git a/lib/src/screens/send/widgets/extract_address_from_parsed.dart b/lib/src/screens/send/widgets/extract_address_from_parsed.dart deleted file mode 100644 index e2564d3f2..000000000 --- a/lib/src/screens/send/widgets/extract_address_from_parsed.dart +++ /dev/null @@ -1,150 +0,0 @@ -import 'package:cake_wallet/entities/parsed_address.dart'; -import 'package:cake_wallet/src/widgets/alert_with_one_action.dart'; -import 'package:cake_wallet/utils/show_pop_up.dart'; -import 'package:flutter/material.dart'; -import 'package:cake_wallet/generated/i18n.dart'; -import 'choose_yat_address_alert.dart'; - -Future extractAddressFromParsed( - BuildContext? context, - ParsedAddress parsedAddress) async { - if (context == null || !context.mounted) return parsedAddress.addresses.first; - - var title = ''; - var content = ''; - var address = ''; - var profileImageUrl = ''; - var profileName = ''; - - switch (parsedAddress.parseFrom) { - case ParseFrom.unstoppableDomains: - title = S.of(context).address_detected; - content = S.of(context).address_from_domain(parsedAddress.name); - address = parsedAddress.addresses.first; - break; - case ParseFrom.ens: - title = S.of(context).address_detected; - content = S.of(context).extracted_address_content('${parsedAddress.name} (ENS)'); - address = parsedAddress.addresses.first; - break; - case ParseFrom.openAlias: - title = S.of(context).address_detected; - content = S.of(context).extracted_address_content('${parsedAddress.name} (OpenAlias)'); - address = parsedAddress.addresses.first; - break; - case ParseFrom.wellKnown: - title = S.of(context).address_detected; - content = S.of(context).extracted_address_content('${parsedAddress.name} (Well-Known)'); - address = parsedAddress.addresses.first; - break; - case ParseFrom.fio: - title = S.of(context).address_detected; - content = S.of(context).extracted_address_content('${parsedAddress.name} (FIO)'); - address = parsedAddress.addresses.first; - break; - case ParseFrom.twitter: - title = S.of(context).address_detected; - content = S.of(context).extracted_address_content('${parsedAddress.name} (Twitter)'); - address = parsedAddress.addresses.first; - profileImageUrl = parsedAddress.profileImageUrl; - profileName = parsedAddress.profileName; - break; - case ParseFrom.mastodon: - title = S.of(context).address_detected; - content = S.of(context).extracted_address_content('${parsedAddress.name} (Mastodon)'); - address = parsedAddress.addresses.first; - profileImageUrl = parsedAddress.profileImageUrl; - profileName = parsedAddress.profileName; - break; - case ParseFrom.nostr: - title = S.of(context).address_detected; - content = S.of(context).extracted_address_content('${parsedAddress.name} (Nostr NIP-05)'); - address = parsedAddress.addresses.first; - profileImageUrl = parsedAddress.profileImageUrl; - profileName = parsedAddress.profileName; - break; - case ParseFrom.thorChain: - title = S.of(context).address_detected; - content = S.of(context).extracted_address_content('${parsedAddress.name} (ThorChain)'); - address = parsedAddress.addresses.first; - break; - case ParseFrom.zanoAlias: - title = S.of(context).address_detected; - content = S.of(context).extracted_address_content('${parsedAddress.name} (Zano Alias)'); - address = parsedAddress.addresses.first; - break; - case ParseFrom.zcashAddress: - title = S.of(context).address_detected; - content = S.of(context).extracted_address_content('${parsedAddress.name} (Zcash.me)'); - address = parsedAddress.addresses.first; - break; - case ParseFrom.zcashName: - title = S.of(context).address_detected; - content = S.of(context).extracted_address_content('${parsedAddress.name} (Zcash Names)'); - address = parsedAddress.addresses.first; - break; - case ParseFrom.bip353: - title = S.of(context).address_detected; - content = S.of(context).extracted_address_content('${parsedAddress.name} (BIP-353)'); - address = parsedAddress.addresses.first; - break; - case ParseFrom.lnurlpay: - title = S.of(context).address_detected; - content = S.of(context).extracted_address_content('${parsedAddress.name} (Lightning)'); - address = parsedAddress.addresses.first; - break; - case ParseFrom.yatRecord: - if (parsedAddress.name.isEmpty) { - title = S.of(context).yat_error; - content = S.of(context).yat_error_content; - address = parsedAddress.addresses.first; - break; - } - - title = S.of(context).address_detected; - content = S.of(context).address_from_yat(parsedAddress.name); - - if (parsedAddress.addresses.length == 1) { - address = parsedAddress.addresses.first; - break; - } - - content += S.of(context).choose_address; - - address = await showPopUp( - context: context, - builder: (context) => PopScope( - child: ChooseYatAddressAlert( - alertTitle: title, - alertContent: content, - addresses: parsedAddress.addresses, - ), - canPop: false, - ), - ) ?? - ''; - - if (address.isEmpty) { - return parsedAddress.name; - } - - return address; - case ParseFrom.contact: - case ParseFrom.notParsed: - return parsedAddress.addresses.first; - } - - await showPopUp( - context: context, - builder: (context) => AlertWithOneAction( - alertTitle: title, - headerTitleText: profileName.isEmpty ? null : profileName, - headerImageProfileUrl: profileImageUrl.isEmpty ? null : profileImageUrl, - alertContent: content, - buttonText: S.of(context).ok, - buttonAction: () => Navigator.of(context).pop(), - ), - ); - - return address; -} diff --git a/lib/src/screens/send/widgets/send_card.dart b/lib/src/screens/send/widgets/send_card.dart index 0472c0e52..7e4b33835 100644 --- a/lib/src/screens/send/widgets/send_card.dart +++ b/lib/src/screens/send/widgets/send_card.dart @@ -594,7 +594,6 @@ class SendCardState extends State with AutomaticKeepAliveClientMixin with AutomaticKeepAliveClientMixin with AutomaticKeepAliveClientMixin with AutomaticKeepAliveClientMixin _connectionsSyncViewModel.setLookupsTwitter(value)), - SettingsSwitcherCell( - title: 'Mastodon', - value: _connectionsSyncViewModel.looksUpMastodon, - onValueChange: (_, bool value) => _connectionsSyncViewModel.setLookupsMastodon(value)), - SettingsSwitcherCell( - title: 'Yat service', - value: _connectionsSyncViewModel.looksUpYatService, - onValueChange: (_, bool value) => _connectionsSyncViewModel.setLookupsYatService(value)), - SettingsSwitcherCell( - title: 'Unstoppable Domains', - value: _connectionsSyncViewModel.looksUpUnstoppableDomains, - onValueChange: (_, bool value) => _connectionsSyncViewModel.setLookupsUnstoppableDomains(value)), - SettingsSwitcherCell( - title: 'OpenAlias', - value: _connectionsSyncViewModel.looksUpOpenAlias, - onValueChange: (_, bool value) => _connectionsSyncViewModel.setLookupsOpenAlias(value)), - SettingsSwitcherCell( - title: 'Ethereum Name Service', - value: _connectionsSyncViewModel.looksUpENS, - onValueChange: (_, bool value) => _connectionsSyncViewModel.setLookupsENS(value)), - SettingsSwitcherCell( - title: 'Zcash Names', - value: _connectionsSyncViewModel.lookupsZcashNames, - onValueChange: (_, bool value) => - _connectionsSyncViewModel.setLookupsZcashNames(value)), - SettingsSwitcherCell( - title: '.well-known', - value: _connectionsSyncViewModel.looksUpWellKnown, - onValueChange: (_, bool value) => _connectionsSyncViewModel.setLookupsWellKnown(value)), - SettingsSwitcherCell( - title: 'Zano Aliases', - value: _connectionsSyncViewModel.lookupsZanoAlias, - onValueChange: (_, bool value) => _connectionsSyncViewModel.setLookupsZanoAlias(value)), - - //if (!isHaven) it does not work correctly - ], + children: _connectionsSyncViewModel.domainLookupSources + .map( + (source) => SettingsSwitcherCell( + title: source.label, + leading: source.iconPath.isNotEmpty ? CakeImageWidget( + imageUrl: source.iconPath, + width: 24, + height: 24, + ) : SizedBox(width: 24, height: 24), + value: _connectionsSyncViewModel.lookupValue(source), + onValueChange: (_, bool value) => + _connectionsSyncViewModel.setLookupValue(source, value), + ), + ) + .toList(), ), ); }), diff --git a/lib/src/screens/wallet_list/filtered_list.dart b/lib/src/screens/wallet_list/filtered_list.dart index 86368210c..5ec27cec5 100644 --- a/lib/src/screens/wallet_list/filtered_list.dart +++ b/lib/src/screens/wallet_list/filtered_list.dart @@ -10,6 +10,7 @@ class FilteredList extends StatefulWidget { this.canReorder = true, this.shrinkWrap = false, this.physics, + this.padding, }); final ObservableList list; @@ -18,6 +19,7 @@ class FilteredList extends StatefulWidget { final bool canReorder; final bool shrinkWrap; final ScrollPhysics? physics; + final EdgeInsets? padding; @override FilteredListState createState() => FilteredListState(); @@ -48,6 +50,7 @@ class FilteredListState extends State { ), shrinkWrap: widget.shrinkWrap, physics: widget.physics ?? const BouncingScrollPhysics(), + padding: widget.padding, itemBuilder: widget.itemBuilder, itemCount: widget.list.length, onReorder: (int oldIndex, int newIndex) { diff --git a/lib/src/screens/yat/yat_popup.dart b/lib/src/screens/yat/yat_popup.dart index 33c94a463..bcf17a4f1 100644 --- a/lib/src/screens/yat/yat_popup.dart +++ b/lib/src/screens/yat/yat_popup.dart @@ -1,10 +1,10 @@ +import 'package:cake_wallet/core/address_resolver/yat/yat_store.dart'; import 'package:cake_wallet/src/screens/yat/widgets/first_introduction.dart'; import 'package:cake_wallet/src/screens/yat/widgets/second_introduction.dart'; import 'package:cake_wallet/src/screens/yat/widgets/third_introduction.dart'; import 'package:cake_wallet/src/screens/yat/widgets/yat_close_button.dart'; import 'package:cake_wallet/src/widgets/alert_background.dart'; import 'package:cake_wallet/src/widgets/primary_button.dart'; -import 'package:cake_wallet/store/yat/yat_store.dart'; import 'package:cake_wallet/view_model/dashboard/dashboard_view_model.dart'; import 'package:flutter/material.dart'; import 'package:animate_do/animate_do.dart'; diff --git a/lib/src/widgets/base_alert_dialog.dart b/lib/src/widgets/base_alert_dialog.dart index e98ae1c07..25c08ff6a 100644 --- a/lib/src/widgets/base_alert_dialog.dart +++ b/lib/src/widgets/base_alert_dialog.dart @@ -1,6 +1,7 @@ import 'dart:ui'; import 'package:auto_size_text/auto_size_text.dart'; +import 'package:cake_wallet/src/widgets/cake_image_widget.dart'; import 'package:cake_wallet/src/widgets/section_divider.dart'; import 'package:flutter/material.dart'; @@ -184,10 +185,7 @@ class BaseAlertDialog extends StatelessWidget { radius: 50, backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest, child: ClipOval( - child: Image.network( - imageUrl, - fit: BoxFit.cover, - ), + child: CakeImageWidget (imageUrl: imageUrl, width: 100, height: 100, fit: BoxFit.cover), ), ), ); diff --git a/lib/src/widgets/bottom_sheet/confirm_sending_bottom_sheet_widget.dart b/lib/src/widgets/bottom_sheet/confirm_sending_bottom_sheet_widget.dart index 140cfa970..965365285 100644 --- a/lib/src/widgets/bottom_sheet/confirm_sending_bottom_sheet_widget.dart +++ b/lib/src/widgets/bottom_sheet/confirm_sending_bottom_sheet_widget.dart @@ -163,7 +163,7 @@ class ConfirmSendingBottomSheet extends BaseBottomSheet { itemBuilder: (context, index) { final isBatchSending = outputs.length > 1; final item = outputs[index]; - final contactName = item.parsedAddress.name; + final contactName = item.parsedAddress.profileName; final isCakePayName = contactName == 'Cake Pay'; final batchContactTitle = '${index + 1}/${outputs.length} - ${contactName.isEmpty ? 'Address' : contactName}'; diff --git a/lib/store/settings_store.dart b/lib/store/settings_store.dart index 732ca03c9..b06e2acb6 100644 --- a/lib/store/settings_store.dart +++ b/lib/store/settings_store.dart @@ -132,7 +132,13 @@ abstract class SettingsStoreBase with Store { required this.lookupsOpenAlias, required this.lookupsENS, required this.lookupsZcashNames, + required this.lookupsZcashAddress, required this.lookupsWellKnown, + required this.lookupsFio, + required this.lookupsNostr, + required this.lookupsThorChain, + required this.lookupsBip353, + required this.lookupsLNUrl, required this.usePayjoin, required this.showPayjoinCard, required this.customBitcoinFeeRate, @@ -567,11 +573,41 @@ abstract class SettingsStoreBase with Store { (bool looksUpZcashNames) => _sharedPreferences.setBool( PreferencesKey.lookupsZcashNames, looksUpZcashNames)); + reaction( + (_) => lookupsZcashAddress, + (bool lookupsZcashAddress) => _sharedPreferences.setBool( + PreferencesKey.lookupsZcashAddress, lookupsZcashAddress)); + reaction( (_) => lookupsWellKnown, (bool looksUpWellKnown) => _sharedPreferences.setBool(PreferencesKey.lookupsWellKnown, looksUpWellKnown)); + reaction( + (_) => lookupsFio, + (bool lookupsFio) => + _sharedPreferences.setBool(PreferencesKey.lookupsFio, lookupsFio)); + + reaction( + (_) => lookupsNostr, + (bool lookupsNostr) => + _sharedPreferences.setBool(PreferencesKey.lookupsNostr, lookupsNostr)); + + reaction( + (_) => lookupsThorChain, + (bool lookupsThorChain) => + _sharedPreferences.setBool(PreferencesKey.lookupsThorChain, lookupsThorChain)); + + reaction( + (_) => lookupsBip353, + (bool lookupsBip353) => + _sharedPreferences.setBool(PreferencesKey.lookupsBip353, lookupsBip353)); + + reaction( + (_) => lookupsLNUrl, + (bool lookupsLNUrl) => + _sharedPreferences.setBool(PreferencesKey.lookupsLNUrl, lookupsLNUrl)); + reaction((_) => usePayjoin, (bool usePayjoin) => _sharedPreferences.setBool(PreferencesKey.usePayjoin, usePayjoin)); @@ -962,9 +998,27 @@ abstract class SettingsStoreBase with Store { @observable bool lookupsZcashNames; + @observable + bool lookupsZcashAddress; + @observable bool lookupsWellKnown; + @observable + bool lookupsFio; + + @observable + bool lookupsNostr; + + @observable + bool lookupsThorChain; + + @observable + bool lookupsBip353; + + @observable + bool lookupsLNUrl; + @observable bool usePayjoin; @@ -1268,9 +1322,14 @@ abstract class SettingsStoreBase with Store { sharedPreferences.getBool(PreferencesKey.lookupsUnstoppableDomains) ?? true; final lookupsOpenAlias = sharedPreferences.getBool(PreferencesKey.lookupsOpenAlias) ?? true; final lookupsENS = sharedPreferences.getBool(PreferencesKey.lookupsENS) ?? true; - final lookupsZcashNames = - sharedPreferences.getBool(PreferencesKey.lookupsZcashNames) ?? true; + final lookupsZcashNames = sharedPreferences.getBool(PreferencesKey.lookupsZcashNames) ?? true; + final lookupsZcashAddress = sharedPreferences.getBool(PreferencesKey.lookupsZcashAddress) ?? true; final lookupsWellKnown = sharedPreferences.getBool(PreferencesKey.lookupsWellKnown) ?? true; + final lookupsFio = sharedPreferences.getBool(PreferencesKey.lookupsFio) ?? true; + final lookupsNostr = sharedPreferences.getBool(PreferencesKey.lookupsNostr) ?? true; + final lookupsThorChain = sharedPreferences.getBool(PreferencesKey.lookupsThorChain) ?? false; + final lookupsBip353 = sharedPreferences.getBool(PreferencesKey.lookupsBip353) ?? true; + final lookupsLNUrl = sharedPreferences.getBool(PreferencesKey.lookupsLNUrl) ?? true; final usePayjoin = sharedPreferences.getBool(PreferencesKey.usePayjoin) ?? false; final showPayjoinCard = sharedPreferences.getBool(PreferencesKey.showPayjoinCard) ?? true; final customBitcoinFeeRate = sharedPreferences.getInt(PreferencesKey.customBitcoinFeeRate) ?? 1; @@ -1659,7 +1718,13 @@ abstract class SettingsStoreBase with Store { lookupsOpenAlias: lookupsOpenAlias, lookupsENS: lookupsENS, lookupsZcashNames: lookupsZcashNames, + lookupsZcashAddress: lookupsZcashAddress, lookupsWellKnown: lookupsWellKnown, + lookupsFio: lookupsFio, + lookupsNostr: lookupsNostr, + lookupsThorChain: lookupsThorChain, + lookupsBip353: lookupsBip353, + lookupsLNUrl: lookupsLNUrl, usePayjoin: usePayjoin, showPayjoinCard: showPayjoinCard, customBitcoinFeeRate: customBitcoinFeeRate, diff --git a/lib/store/yat/yat_exception.dart b/lib/store/yat/yat_exception.dart deleted file mode 100644 index 71313ba88..000000000 --- a/lib/store/yat/yat_exception.dart +++ /dev/null @@ -1,10 +0,0 @@ -import 'package:flutter/foundation.dart'; - -class YatException implements Exception { - YatException({required this.text}); - - final String text; - - @override - String toString() => 'Yat exception: $text'; -} \ No newline at end of file diff --git a/lib/view_model/contact_list/contact_view_model.dart b/lib/view_model/contact_list/contact_view_model.dart index c808f966c..d0d74b953 100644 --- a/lib/view_model/contact_list/contact_view_model.dart +++ b/lib/view_model/contact_list/contact_view_model.dart @@ -1,7 +1,10 @@ +import 'package:cake_wallet/core/address_resolver/address_resolver_service.dart'; +import 'package:cake_wallet/core/address_resolver/parsed_address.dart'; +import 'package:cake_wallet/core/address_validator.dart'; import 'package:cake_wallet/di.dart'; import 'package:cake_wallet/entities/contact_record.dart'; -import 'package:cake_wallet/entities/parse_address_from_domain.dart'; -import 'package:cake_wallet/src/screens/send/widgets/extract_address_from_parsed.dart'; +import 'package:cake_wallet/main.dart'; +import 'package:cake_wallet/store/app_store.dart'; import 'package:cw_core/utils/print_verbose.dart'; import 'package:flutter/material.dart'; import 'package:hive/hive.dart'; @@ -16,7 +19,8 @@ part 'contact_view_model.g.dart'; class ContactViewModel = ContactViewModelBase with _$ContactViewModel; abstract class ContactViewModelBase with Store { - ContactViewModelBase(this._contacts, {ContactRecord? contact}) + ContactViewModelBase(this._contacts, this.appStore,this.adrResService, + {ContactRecord? contact}) : state = InitialExecutionState(), currencies = CryptoCurrency.all, _contact = contact, @@ -26,6 +30,9 @@ abstract class ContactViewModelBase with Store { currency = contact?.type, lastChange = contact?.lastChange; + final AppStore appStore; + final AddressResolverService adrResService; + @observable ExecutionState state; @@ -58,16 +65,52 @@ abstract class ContactViewModelBase with Store { void reset() { address = ''; name = ''; + displayName = ''; currency = null; } - Future extractParsedAddress(BuildContext context) async{ - if(currency == null) return; - final parsedAddress = await getIt.get().resolve(context, address, currency!); - if(parsedAddress.name.isNotEmpty) { - displayName = parsedAddress.name; + Future extractParsedAddress(BuildContext context) async { + final wallet = appStore.wallet; + final currentCurrency = currency; + final query = address.trim(); + + if (wallet == null) return null; + if (currentCurrency == null) return null; + if (query.isEmpty) return null; + + + // Check if the address is valid for the current currency (except for Zano, which can use handles as addresses) + if (currentCurrency != CryptoCurrency.zano) { + final isValidAddress = AddressValidator(type: currentCurrency).isValid(query); + if (isValidAddress) return null; } - address = await extractAddressFromParsed(null, parsedAddress); + + final parsedAddresses = await adrResService.resolve( + query: query, + wallet: wallet, + currency: currentCurrency, + ); + + if (parsedAddresses.isEmpty) return null; + + final resolvedAddress = parsedAddresses.first.parsedAddressByCurrencyMap[currentCurrency]; + if (resolvedAddress == null || resolvedAddress.isEmpty) return null; + + return parsedAddresses.first; + } + + @action + void applyParsedAddress(ParsedAddress parsedAddress) { + final currentCurrency = currency; + if (currentCurrency == null) return; + + final resolvedAddress = parsedAddress.parsedAddressByCurrencyMap[currentCurrency]; + if (resolvedAddress == null || resolvedAddress.isEmpty) return; + + address = resolvedAddress; + displayName = parsedAddress.profileName.isNotEmpty + ? parsedAddress.profileName + : parsedAddress.handle; } Future save() async { diff --git a/lib/view_model/dashboard/dashboard_view_model.dart b/lib/view_model/dashboard/dashboard_view_model.dart index d97d119a4..f9d355c5a 100644 --- a/lib/view_model/dashboard/dashboard_view_model.dart +++ b/lib/view_model/dashboard/dashboard_view_model.dart @@ -4,6 +4,7 @@ import 'dart:io' show Platform; import 'package:cake_wallet/.secrets.g.dart' as secrets; import 'package:cake_wallet/bitcoin/bitcoin.dart'; +import 'package:cake_wallet/core/address_resolver/yat/yat_store.dart'; import 'package:cake_wallet/core/key_service.dart'; import "package:cw_core/balance_card_style_settings.dart"; import 'package:cake_wallet/core/trade_monitor.dart'; @@ -35,7 +36,6 @@ import 'package:cake_wallet/store/dashboard/trade_filter_store.dart'; import 'package:cake_wallet/store/dashboard/trades_store.dart'; import 'package:cake_wallet/store/dashboard/transaction_filter_store.dart'; import 'package:cake_wallet/store/settings_store.dart'; -import 'package:cake_wallet/store/yat/yat_store.dart'; import 'package:cake_wallet/view_model/dashboard/action_list_item.dart'; import 'package:cake_wallet/view_model/dashboard/anonpay_transaction_list_item.dart'; import 'package:cake_wallet/view_model/dashboard/balance_view_model.dart'; diff --git a/lib/view_model/restore/wallet_restore_from_qr_code.dart b/lib/view_model/restore/wallet_restore_from_qr_code.dart index 4217aab59..732729d6c 100644 --- a/lib/view_model/restore/wallet_restore_from_qr_code.dart +++ b/lib/view_model/restore/wallet_restore_from_qr_code.dart @@ -1,7 +1,7 @@ import 'dart:convert'; +import 'package:cake_wallet/core/address_resolver/address_resolver_utils.dart'; import 'package:cake_wallet/core/seed_validator.dart'; -import 'package:cake_wallet/entities/parse_address_from_domain.dart'; import 'package:cake_wallet/entities/qr_scanner.dart'; import 'package:cake_wallet/reactions/wallet_connect.dart'; import 'package:cake_wallet/routes.dart'; @@ -87,7 +87,7 @@ class WalletRestoreFromQRCode { static String? _extractAddressFromUrl(String rawString, WalletType type) { try { - return AddressResolver.extractAddressByType( + return AddressResolverUtils.extractAddressByType( raw: rawString, type: walletTypeToCryptoCurrency( type, diff --git a/lib/view_model/send/output.dart b/lib/view_model/send/output.dart index 543a541bd..ef4609386 100644 --- a/lib/view_model/send/output.dart +++ b/lib/view_model/send/output.dart @@ -1,16 +1,16 @@ import 'dart:math' show min; import 'package:cake_wallet/bitcoin/bitcoin.dart'; +import 'package:cake_wallet/core/address_resolver/address_sources.dart'; +import 'package:cake_wallet/core/address_resolver/parsed_address.dart'; +import 'package:cake_wallet/decred/decred.dart'; import 'package:cake_wallet/di.dart'; import 'package:cake_wallet/entities/calculate_fiat_amount.dart'; import 'package:cake_wallet/entities/calculate_fiat_amount_raw.dart'; import 'package:cake_wallet/entities/contact_base.dart'; -import 'package:cake_wallet/entities/parse_address_from_domain.dart'; -import 'package:cake_wallet/entities/parsed_address.dart'; import 'package:cake_wallet/evm/evm.dart'; import 'package:cake_wallet/generated/i18n.dart'; import 'package:cake_wallet/reactions/wallet_connect.dart'; import 'package:cake_wallet/solana/solana.dart'; -import 'package:cake_wallet/src/screens/send/widgets/extract_address_from_parsed.dart'; import 'package:cake_wallet/store/app_store.dart'; import 'package:cake_wallet/store/dashboard/fiat_conversion_store.dart'; import 'package:cake_wallet/store/settings_store.dart'; @@ -37,7 +37,8 @@ class Output = OutputBase with _$Output; abstract class OutputBase with Store { OutputBase(this._wallet, this._appStore, this._fiatConversationStore, this.cryptoCurrencyHandler) - : key = UniqueKey(), + : + key = UniqueKey(), sendAll = false, cryptoAmount = '', cryptoFullBalance = '', @@ -47,7 +48,7 @@ abstract class OutputBase with Store { memo = "", extractedAddress = '', estimatedFee = Money.zero(cryptoCurrencyHandler()), - parsedAddress = ParsedAddress(addresses: []) { + parsedAddress = ParsedAddress(parsedAddressByCurrencyMap: {}) { autorun((_) { final status = _wallet.syncStatus; printV("Sync status changed to $status. Recalculating fees"); @@ -56,6 +57,7 @@ abstract class OutputBase with Store { }); } + Key key; bool get useSatoshi => _appStore.amountParsingProxy.useSatoshi(cryptoCurrencyHandler()); @@ -83,7 +85,7 @@ abstract class OutputBase with Store { @observable String note; - + @observable String memo; @@ -99,7 +101,7 @@ abstract class OutputBase with Store { @computed bool get isParsedAddress => - parsedAddress.parseFrom != ParseFrom.notParsed && parsedAddress.name.isNotEmpty; + parsedAddress.addressSource != AddressSource.notParsed && parsedAddress.handle.isNotEmpty; String roundedCryptoAmount(int digits) => displayCryptoAmount.withMaxDecimals(digits); @@ -272,10 +274,22 @@ abstract class OutputBase with Store { resetParsedAddress(); } + @action void resetParsedAddress() { displayName = null; extractedAddress = ''; - parsedAddress = ParsedAddress(addresses: []); + note = ''; + parsedAddress = ParsedAddress(parsedAddressByCurrencyMap: {}); + } + + @action + void applyAddressLookupResult(ParsedAddress result) { + final currency = cryptoCurrencyHandler(); + + parsedAddress = result; + extractedAddress = result.parsedAddressByCurrencyMap[currency] ?? ''; + note = result.description; + displayName = result.profileName.isNotEmpty ? result.profileName : result.handle; } @action @@ -334,33 +348,26 @@ abstract class OutputBase with Store { Map get extra { final fields = {}; - if (parsedAddress.parseFrom == ParseFrom.bip353) { - fields['bip353_name'] = parsedAddress.name; + if (parsedAddress.addressSource == AddressSource.bip353) { + fields['bip353_name'] = parsedAddress.handle; fields['bip353_proof'] = parsedAddress.bip353DnsProof; } return fields; } - Future fetchParsedAddress(BuildContext context) async { - final domain = address; - final currency = cryptoCurrencyHandler(); - parsedAddress = await getIt.get().resolve(context, domain, currency); - if(parsedAddress.name.isNotEmpty) { - displayName = parsedAddress.name; - } - - if (parsedAddress.parseFrom == ParseFrom.lnurlpay) { - cryptoCurrencyHandler(CryptoCurrency.btcln); - } - extractedAddress = await extractAddressFromParsed(context, parsedAddress); - note = parsedAddress.description; - } - + @action void loadContact(ContactBase contact) { - address = contact.name; - parsedAddress = ParsedAddress.fetchContactAddress(address: contact.address, name: contact.name); - extractedAddress = parsedAddress.addresses.first; - note = parsedAddress.description; + final currency = cryptoCurrencyHandler(); + + address = contact.address; + applyAddressLookupResult( + ParsedAddress( + parsedAddressByCurrencyMap: {currency: contact.address}, + addressSource: AddressSource.contact, + handle: contact.name, + profileName: contact.name, + ), + ); } } diff --git a/lib/view_model/send/send_view_model.dart b/lib/view_model/send/send_view_model.dart index 5c4b2c09c..61990c195 100644 --- a/lib/view_model/send/send_view_model.dart +++ b/lib/view_model/send/send_view_model.dart @@ -1,6 +1,9 @@ import 'dart:async'; import 'package:cake_wallet/bitcoin/bitcoin.dart'; +import 'package:cake_wallet/core/address_resolver/parsed_address.dart'; +import 'package:cake_wallet/core/address_resolver/address_resolver_service.dart'; +import 'package:cake_wallet/di.dart'; import 'package:cake_wallet/core/address_validator.dart'; import 'package:cake_wallet/core/amount_parsing_proxy.dart'; import 'package:cake_wallet/core/amount_validator.dart'; @@ -17,7 +20,6 @@ import 'package:cake_wallet/entities/contact.dart'; import 'package:cake_wallet/entities/contact_record.dart'; import 'package:cake_wallet/entities/evm_transaction_error_fees_handler.dart'; import 'package:cake_wallet/entities/fiat_currency.dart'; -import 'package:cake_wallet/entities/parsed_address.dart'; import 'package:cake_wallet/entities/preferences_key.dart'; import 'package:cake_wallet/entities/template.dart'; import 'package:cake_wallet/entities/transaction_description.dart'; @@ -103,6 +105,7 @@ abstract class SendViewModelBase extends WalletChangeListenerViewModel with Stor this._appStore, this.sendTemplateViewModel, this._fiatConversationStore, + this._adrResService, this.balanceViewModel, this.contactListViewModel, this.transactionDescriptionBox, @@ -182,6 +185,26 @@ abstract class SendViewModelBase extends WalletChangeListenerViewModel with Stor if (isBatchSending) outputs.remove(output); } + Future resolveAddressForOutput(Output output) async { + final query = output.address.trim(); + if (query.isEmpty) return null; + + // Check if the address is valid for the current currency (except for Zano, which can use handles as addresses) + if (selectedCryptoCurrency != CryptoCurrency.zano) { + final isValidAddress = AddressValidator(type: selectedCryptoCurrency).isValid(query); + if (isValidAddress) return null; + } + + + final results = await _adrResService.resolve( + query: query, + wallet: wallet, + currency: selectedCryptoCurrency, + ); + + return results.isEmpty ? null : results.first; + } + @action void clearOutputs() { outputs.clear(); @@ -383,14 +406,17 @@ abstract class SendViewModelBase extends WalletChangeListenerViewModel with Stor // If silent payments scanning, can still send payments (wallet.type == WalletType.bitcoin && wallet.syncStatus is SyncingSyncStatus); - bool isSendToSilentPayments(Output output) => - wallet.type == WalletType.bitcoin && - (RegExp(AddressValidator.silentPaymentAddressPatternMainnet).hasMatch(output.address) || - RegExp(AddressValidator.silentPaymentAddressPatternMainnet) - .hasMatch(output.extractedAddress) || - (output.parsedAddress.addresses.isNotEmpty && - RegExp(AddressValidator.silentPaymentAddressPatternMainnet) - .hasMatch(output.parsedAddress.addresses[0]))); + bool isSendToSilentPayments(Output output) { + if (wallet.type != WalletType.bitcoin) return false; + + final sp = RegExp(AddressValidator.silentPaymentAddressPatternMainnet); + + final address = output.extractedAddress.isNotEmpty + ? output.extractedAddress + : output.address; + + return sp.hasMatch(address); + } @computed List