diff --git a/assets/images/add_contact_coins_img.png b/assets/images/add_contact_coins_img.png new file mode 100644 index 0000000000..32fc4556fb Binary files /dev/null and b/assets/images/add_contact_coins_img.png differ diff --git a/assets/images/address_providers/bip353.svg b/assets/images/address_providers/bip353.svg new file mode 100644 index 0000000000..c8aea38119 --- /dev/null +++ b/assets/images/address_providers/bip353.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/images/address_providers/ens.svg b/assets/images/address_providers/ens.svg new file mode 100644 index 0000000000..6a4e0e4870 --- /dev/null +++ b/assets/images/address_providers/ens.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/assets/images/address_providers/fio.svg b/assets/images/address_providers/fio.svg new file mode 100644 index 0000000000..d657995d5f --- /dev/null +++ b/assets/images/address_providers/fio.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/assets/images/address_providers/mastodon.svg b/assets/images/address_providers/mastodon.svg new file mode 100644 index 0000000000..5d3c0a3a7c --- /dev/null +++ b/assets/images/address_providers/mastodon.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/assets/images/address_providers/nostr.svg b/assets/images/address_providers/nostr.svg new file mode 100644 index 0000000000..0c200a25e7 --- /dev/null +++ b/assets/images/address_providers/nostr.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/images/address_providers/openalias.svg b/assets/images/address_providers/openalias.svg new file mode 100644 index 0000000000..d119b93865 --- /dev/null +++ b/assets/images/address_providers/openalias.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/assets/images/address_providers/thorchain.svg b/assets/images/address_providers/thorchain.svg new file mode 100644 index 0000000000..9db98af5d7 --- /dev/null +++ b/assets/images/address_providers/thorchain.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/assets/images/address_providers/unstoppable.svg b/assets/images/address_providers/unstoppable.svg new file mode 100644 index 0000000000..4fd89b2e2d --- /dev/null +++ b/assets/images/address_providers/unstoppable.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/images/address_providers/wellknown.svg b/assets/images/address_providers/wellknown.svg new file mode 100644 index 0000000000..dd993a3754 --- /dev/null +++ b/assets/images/address_providers/wellknown.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/images/address_providers/x.svg b/assets/images/address_providers/x.svg new file mode 100644 index 0000000000..5c3a58d96d --- /dev/null +++ b/assets/images/address_providers/x.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/assets/images/address_providers/yat.svg b/assets/images/address_providers/yat.svg new file mode 100644 index 0000000000..2cc039a473 --- /dev/null +++ b/assets/images/address_providers/yat.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/assets/images/address_providers/zano.svg b/assets/images/address_providers/zano.svg new file mode 100644 index 0000000000..5a24e38e64 --- /dev/null +++ b/assets/images/address_providers/zano.svg @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/images/trash_can_icon.png b/assets/images/trash_can_icon.png new file mode 100644 index 0000000000..077f0650db Binary files /dev/null and b/assets/images/trash_can_icon.png differ diff --git a/lib/address_resolver/address_resolver_service.dart b/lib/address_resolver/address_resolver_service.dart new file mode 100644 index 0000000000..87d194a663 --- /dev/null +++ b/lib/address_resolver/address_resolver_service.dart @@ -0,0 +1,716 @@ +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/fio_address_provider.dart'; +import 'package:cake_wallet/entities/openalias_record.dart'; +import 'package:cake_wallet/address_resolver/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/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/wallet_base.dart'; + +import '../entities/bip_353_record.dart'; + +class AddressResolverService { + AddressResolverService({required this.yatService, required this.settingsStore}) { + _buildLookupTable(); + } + + final YatService yatService; + 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", + "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" + ]; + + late final List _lookupTable; + + void _buildLookupTable() { + _lookupTable = [ + LookupEntry( + source: AddressSource.twitter, + currencies: AddressSource.twitter.supportedCurrencies, + applies: (q) => settingsStore.lookupsTwitter && q.startsWith('@'), + // x handle example: @username + run: _lookupTwitter, + ), + LookupEntry( + source: AddressSource.zanoAlias, + currencies: AddressSource.zanoAlias.supportedCurrencies, + applies: (q) => settingsStore.lookupsZanoAlias && q.startsWith('@'), + // zano handle example: @username + run: _lookupZano, + ), + LookupEntry( + source: AddressSource.mastodon, + currencies: AddressSource.mastodon.supportedCurrencies, + applies: (q) => + settingsStore.lookupsMastodon && + q.startsWith('@') && + q.contains('@', 1) && + q.contains('.', 1), + // Mastodon handle example: @username@hostname.xxx + run: _lookupMastodon, + ), + LookupEntry( + source: AddressSource.wellKnown, + currencies: AddressSource.wellKnown.supportedCurrencies, + applies: (q) => settingsStore.lookupsWellKnown && q.contains('.') && q.contains('@'), + // .well-known handle example: + run: _lookupWellKnown, + ), + LookupEntry( + source: AddressSource.fio, + currencies: AddressSource.fio.supportedCurrencies, + applies: (q) => + settingsStore.lookupsFio && !q.startsWith('@') && q.contains('@') && !q.contains('.'), + // FIO handle example: username@domain + run: _lookupFio, + ), + LookupEntry( + source: AddressSource.yatRecord, + currencies: AddressSource.yatRecord.supportedCurrencies, + applies: (q) => settingsStore.lookupsYatService && q.hasOnlyEmojis, + // Yat handle example: 🐶🐾 + run: _lookupYatService, + ), + LookupEntry( + source: AddressSource.thorChain, + currencies: AddressSource.thorChain.supportedCurrencies, + applies: (q) => settingsStore.lookupsThorChain && q.isNotEmpty, + run: _lookupThorChain, + ), + LookupEntry( + source: AddressSource.unstoppableDomains, + currencies: AddressSource.unstoppableDomains.supportedCurrencies, + applies: (q) { + if (!settingsStore.lookupsUnstoppableDomains) return false; + // Unstoppable Domains handle example: name.crypto + final formattedName = OpenaliasRecord.formatDomainName(q); + final domainParts = formattedName.split('.'); + final name = domainParts.last; + return domainParts.length > 1 && + domainParts.first.isNotEmpty && + name.isNotEmpty && + unstoppableDomains.any((domain) => name.trim() == domain); + }, + run: _lookupsUnstoppableDomains, + ), + LookupEntry( + source: AddressSource.bip353, + currencies: AddressSource.bip353.supportedCurrencies, + applies: (q) => settingsStore.lookupsBip353 && q.contains('@') && q.contains('.'), + run: _lookupsBip353, + ), + LookupEntry( + source: AddressSource.ens, + currencies: AddressSource.ens.supportedCurrencies, + applies: (q) => settingsStore.lookupsENS && q.endsWith('.eth'), + // ENS handle example: name.eth + run: _lookupEns, + ), + LookupEntry( + source: AddressSource.openAlias, + currencies: AddressSource.openAlias.supportedCurrencies, + applies: (q) { + if (!settingsStore.lookupsOpenAlias) return false; + // OpenAlias handle example: + final formattedName = OpenaliasRecord.formatDomainName(q); + return formattedName.contains("."); + }, + run: _lookupsOpenAlias, + ), + LookupEntry( + source: AddressSource.nostr, + currencies: AddressSource.nostr.supportedCurrencies, + applies: (q) => settingsStore.lookupsNostr && isEmailFormat(q), + // Nostr handle example: name@domain + run: _lookupsNostr, + ), + ]; + } + + static String _cleanInput(String raw) => + raw.replaceAll(RegExp(r'[\u2028\u2029]'), '\n').replaceAll(RegExp(r'<[^>]+>'), ' '); + + 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 = _cleanInput(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 ''; + }); + } + + 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); + } + + Future> resolve({ + required String query, + required WalletBase wallet, + CryptoCurrency? currency, + }) async { + try { + final tasks = >[]; + + for (final entry in _lookupTable) { + if (!supportedSources.contains(entry.source)) continue; + if (!entry.applies(query)) continue; + + final coins = currency == null + ? entry.currencies.toList() + : (entry.currencies.contains(currency) ? [currency] : const []); + + if (coins.isEmpty) continue; + tasks.add(entry.run(query, coins, wallet)); + } + + final results = await Future.wait(tasks); + + return results.whereType().toList(); + } catch (e) { + printV('Error resolving address: $e'); + return []; + } + } + + Future _lookupTwitter( + String text, List currencies, WalletBase wallet) async { + final formattedName = text.substring(1); + final twitterUser = await TwitterApi.lookupUserByName(userName: formattedName); + + if (twitterUser == null) return null; + + final Map result = {}; + + String queryTxt = twitterUser.description; + + try { + for (final cur in currencies) { + final addressFromBio = extractAddressByType( + raw: queryTxt, type: CryptoCurrency.fromString(cur.title)); + printV('Address from bio: $addressFromBio'); + + if (addressFromBio != null && addressFromBio.isNotEmpty) { + result[cur] = addressFromBio; + queryTxt = queryTxt.replaceFirst(addressFromBio, ''); + } + } + } catch (e) { + printV('Error extracting address from Twitter bio: $e'); + } + + String pinnedTweet = twitterUser.pinnedTweet?.text ?? ''; + + try { + + if (pinnedTweet.isNotEmpty) { + for (final cur in currencies) { + final addressFromPinnedTweet = + extractAddressByType(raw: pinnedTweet, type: CryptoCurrency.fromString(cur.title)); + if (addressFromPinnedTweet != null && addressFromPinnedTweet.isNotEmpty) { + result[cur] = addressFromPinnedTweet; + pinnedTweet = pinnedTweet = pinnedTweet.replaceFirst(addressFromPinnedTweet, ''); + } + } + } + } catch (e) { + printV('Error extracting address from Twitter pinned tweet: $e'); + } + + if (result.isNotEmpty) { + return ParsedAddress( + parsedAddressByCurrencyMap: result, + addressSource: AddressSource.twitter, + handle: text, + profileImageUrl: twitterUser.profileImageUrl, + profileName: twitterUser.name, + ); + } + return null; + } + + Future _lookupZano(String text, List _, WalletBase __) async { + final formattedName = text.substring(1); + + final zanoAddress = await ZanoAlias.fetchZanoAliasAddress(formattedName); + if (zanoAddress != null && zanoAddress.isNotEmpty) { + return ParsedAddress( + parsedAddressByCurrencyMap: {CryptoCurrency.zano: zanoAddress}, + addressSource: AddressSource.zanoAlias, + handle: text, + ); + } + return null; + } + + Future _lookupMastodon( + String text, List currencies, WalletBase _) async { + final subText = text.substring(1); + final hostNameIndex = subText.indexOf('@'); + final hostName = subText.substring(hostNameIndex + 1); + final userName = subText.substring(0, hostNameIndex); + + final Map result = {}; + + final mastodonUser = + await MastodonAPI.lookupUserByUserName(userName: userName, apiHost: hostName); + + if (mastodonUser != null) { + String queryTxt = mastodonUser.note; + for (final cur in currencies) { + String? addressFromBio = extractAddressByType(raw: queryTxt, type: cur); + if (addressFromBio != null && addressFromBio.isNotEmpty) { + result[cur] = addressFromBio; + queryTxt = queryTxt.replaceFirst(addressFromBio, ''); + } + } + + final pinnedPosts = + await MastodonAPI.getPinnedPosts(userId: mastodonUser.id, apiHost: hostName); + + if (pinnedPosts.isNotEmpty) { + String userPinnedPostsText = pinnedPosts.map((item) => item.content).join('\n'); + + + for (final cur in currencies) { + String? addressFromPinnedPost = extractAddressByType(raw: userPinnedPostsText, type: cur); + if (addressFromPinnedPost != null && addressFromPinnedPost.isNotEmpty) { + result[cur] = addressFromPinnedPost; + userPinnedPostsText = userPinnedPostsText.replaceFirst(addressFromPinnedPost, ''); + } + } + } + + if (result.isNotEmpty) { + return ParsedAddress( + parsedAddressByCurrencyMap: result, + addressSource: AddressSource.mastodon, + handle: text, + profileImageUrl: mastodonUser.profileImageUrl, + profileName: mastodonUser.username, + ); + } + } + return null; + } + + Future _lookupWellKnown( + String text, List currencies, WalletBase _) async { + if (!currencies.contains(CryptoCurrency.nano)) return null; + + final rec = await WellKnownRecord.fetch(text, CryptoCurrency.nano); + if (rec == null || rec.address.isEmpty) return null; + + return ParsedAddress( + parsedAddressByCurrencyMap: {CryptoCurrency.nano: rec.address}, + addressSource: AddressSource.wellKnown, + handle: text, + profileName: rec.title ?? '', + profileImageUrl: rec.imageUrl ?? '', + ); + } + + Future _lookupFio( + String text, List currencies, WalletBase _) async { + final Map result = {}; + final bool isFioRegistered = await FioAddressProvider.checkAvail(text); + if (!isFioRegistered) return null; + + for (final cur in currencies) { + final address = await FioAddressProvider.getPubAddress(text, cur.title); + if (address != null && address.isNotEmpty) { + result[cur] = address; + } + } + + if (result.isNotEmpty) { + return ParsedAddress( + parsedAddressByCurrencyMap: result, + addressSource: AddressSource.fio, + handle: text, + ); + } + return null; + } + + Future _lookupYatService( + String text, List currencies, WalletBase _) async { + final result = {}; + + for (final cur in currencies) { + final records = await yatService.fetchYatAddress(text, 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; + } + + return result.isEmpty + ? null + : ParsedAddress( + parsedAddressByCurrencyMap: result, + addressSource: AddressSource.yatRecord, + handle: text, + ); + } + + Future _lookupThorChain( + String text, List currencies, WalletBase _) async { + final map = await ThorChainExchangeProvider.lookupAddressByName(text); + if (map == null || map.isEmpty) return null; + + final result = {}; + + for (final cur in currencies) { + final key = cur.title.toUpperCase(); + final addr = map[key]; + if (addr != null && addr.isNotEmpty) { + if (!result.containsValue(addr)) result[cur] = addr; + } + } + + return result.isEmpty + ? null + : ParsedAddress( + parsedAddressByCurrencyMap: result, + addressSource: AddressSource.thorChain, + handle: text, + ); + } + + Future _lookupsUnstoppableDomains( + String text, List currency, WalletBase _) async { + final Map result = {}; + for (final cur in currency) { + final address = await fetchUnstoppableDomainAddress(text, cur.title); + if (address.isNotEmpty) { + result[cur] = address; + } + } + + if (result.isNotEmpty) { + return ParsedAddress( + parsedAddressByCurrencyMap: result, + profileImageUrl: 'assets/images/profile.png', + profileName: text, + addressSource: AddressSource.unstoppableDomains, + handle: text, + ); + } + + return null; + } + + Future _lookupsBip353( + String text, List currencies, WalletBase _) async { + final Map result = {}; + + String? dnsProof; + try { + dnsProof = await Bip353Record.fetchDnsProof(text); + + for (final cur in currencies) { + final bip353AddressMap = await Bip353Record.fetchUriByCryptoCurrency(text, cur.title); + + if (bip353AddressMap != null && bip353AddressMap.isNotEmpty) { + if (cur == CryptoCurrency.btc) { + bip353AddressMap.forEach((key, value) { + final spAddress = bip353AddressMap['sp']; + final address = bip353AddressMap['address']; + + if (spAddress != null && spAddress.isNotEmpty) { + result[cur] = spAddress; + } + if (address != null && address.isNotEmpty) { + result[cur] = address; + } + }); + } + } + } + } catch (e) { + printV('Error fetching BIP-353 DNS proof: $e'); + return null; + } + + if (result.isNotEmpty) { + return ParsedAddress( + parsedAddressByCurrencyMap: result, + addressSource: AddressSource.bip353, + handle: text, + bip353DnsProof: dnsProof ?? '', + ); + } + return null; + } + + Future _lookupEns( + String text, List currency, WalletBase wallet) async { + final Map result = {}; + + for (final cur in currency) { + final address = await EnsRecord.fetchEnsAddress(text, cur, wallet: wallet); + if (address.isNotEmpty && address != "0x0000000000000000000000000000000000000000") { + result[cur] = address; + } + } + + if (result.isNotEmpty) { + return ParsedAddress( + parsedAddressByCurrencyMap: result, + addressSource: AddressSource.ens, + handle: text, + ); + } + return null; + } + + Future _lookupsOpenAlias( + String text, + List currencies, + WalletBase _, + ) async { + final formatted = OpenaliasRecord.formatDomainName(text); + + final txtRecords = await OpenaliasRecord.lookupOpenAliasRecord(formatted); + if (txtRecords == null) return null; + + 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; + } + + return result.isEmpty + ? null + : ParsedAddress( + parsedAddressByCurrencyMap: result, + addressSource: AddressSource.openAlias, + handle: text, + ); + } + + Future _lookupsNostr( + String text, List currencies, WalletBase _) async { + + try { + final profile = await NostrProfileHandler.queryProfile(text); + if (profile == null) return null; + + final data = await NostrProfileHandler.processRelays(profile, text); + if (data == null) return null; + + final result = {}; + + String queryTxt = data.about; + + for (final cur in currencies) { + final addr = extractAddressByType(raw: queryTxt, type: cur); + if (addr != null && addr.isNotEmpty) { + result[cur] = addr; + queryTxt = queryTxt.replaceFirst(addr, ''); + } + } + if (result.isEmpty) return null; + + return ParsedAddress( + parsedAddressByCurrencyMap: result, + addressSource: AddressSource.nostr, + handle: text, + profileImageUrl: data.picture, + profileName: data.name, + ); + } catch (e) { + printV('Error looking up Nostr profile: $e'); + return null; + } + } +} + +class LookupEntry { + const LookupEntry({ + required this.source, + required this.currencies, + required this.applies, + required this.run, + }); + + final AddressSource source; + final List currencies; + final bool Function(String query) applies; + final Future Function( + String query, List currencies, WalletBase wallet) run; +} diff --git a/lib/address_resolver/parsed_address.dart b/lib/address_resolver/parsed_address.dart new file mode 100644 index 0000000000..ea51edd19f --- /dev/null +++ b/lib/address_resolver/parsed_address.dart @@ -0,0 +1,163 @@ +import 'package:cake_wallet/core/address_validator.dart'; +import 'package:cake_wallet/entities/openalias_record.dart'; +import 'package:cake_wallet/entities/yat_record.dart'; +import 'package:cw_core/crypto_currency.dart'; + +const supportedSources = [ + AddressSource.twitter, + AddressSource.unstoppableDomains, + AddressSource.mastodon, + AddressSource.bip353, + AddressSource.fio, + AddressSource.zanoAlias, + AddressSource.thorChain, + AddressSource.ens, + AddressSource.yatRecord, + AddressSource.openAlias, + AddressSource.wellKnown, + AddressSource.nostr, +]; + +///Do not use '-' in the label, it is used to separate the label from the alias. +enum AddressSource { + twitter( + label: 'X', + iconPath: 'assets/images/address_providers/x.svg', + alias: '@username', + supportedCurrencies: AddressValidator.reliableValidateCurrencies), + unstoppableDomains( + label: 'Unstoppable Domains', + iconPath: 'assets/images/address_providers/unstoppable.svg', + alias: 'domain.tld', + supportedCurrencies: [CryptoCurrency.xmr, CryptoCurrency.btc]), + openAlias( + label: 'OpenAlias', + iconPath: 'assets/images/address_providers/openalias.svg', + alias: 'name.domain.tld', + supportedCurrencies: [CryptoCurrency.xmr, CryptoCurrency.btc]), + yatRecord( + label: 'Yat', + iconPath: 'assets/images/address_providers/yat.svg', + alias: '🎂🎂🎂', + supportedCurrencies: [ + CryptoCurrency.xmr, + CryptoCurrency.btc, + CryptoCurrency.eth, + CryptoCurrency.ltc + ]), + fio(label: 'FIO', iconPath: 'assets/images/address_providers/fio.svg', alias: 'user@domain', + supportedCurrencies: AddressValidator.reliableValidateCurrencies), + ens( + label: 'Ethereum Name Service', + iconPath: 'assets/images/address_providers/ens.svg', + alias: 'domain.eth', + supportedCurrencies: [CryptoCurrency.xmr, CryptoCurrency.btc, CryptoCurrency.eth]), + mastodon( + label: 'Mastodon', + iconPath: 'assets/images/address_providers/mastodon.svg', + alias: 'user@domain.tld', + supportedCurrencies: AddressValidator.reliableValidateCurrencies), + nostr( + label: 'Nostr', + iconPath: 'assets/images/address_providers/nostr.svg', + alias: 'user@domain.tld', + supportedCurrencies: AddressValidator.reliableValidateCurrencies), + thorChain( + label: 'ThorChain', + iconPath: 'assets/images/address_providers/thorchain.svg', + alias: 'name', + supportedCurrencies: CryptoCurrency.all), + wellKnown( + label: '.wellknown', + iconPath: 'assets/images/address_providers/wellknown.svg', + alias: 'domain.tld', + supportedCurrencies: [CryptoCurrency.nano]), + zanoAlias( + label: 'Zano Alias', + iconPath: 'assets/images/address_providers/zano.svg', + alias: '@alias', + supportedCurrencies: [CryptoCurrency.zano]), + bip353( + label: 'BIP353', + iconPath: 'assets/images/address_providers/bip353.svg', + alias: 'user@domain.com', + supportedCurrencies: [CryptoCurrency.btc]), + contact(label: 'Contact', iconPath: '', supportedCurrencies: []), + notParsed(label: 'Unknown', iconPath: '', supportedCurrencies: []); + + const AddressSource({ + required this.label, + required this.iconPath, + this.alias = '', + this.supportedCurrencies = const [], + }); + + final String label; + final String iconPath; + final String alias; + final List supportedCurrencies; +} + +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, + ); + } +} + +class ParsedAddress { + const ParsedAddress({ + required this.parsedAddressByCurrencyMap, + this.manualAddressByCurrencyMap, + this.addressSource = AddressSource.notParsed, + this.handle = '', + this.profileImageUrl = '', + this.profileName = '', + this.description = '', + this.bip353DnsProof, + }); + + final Map parsedAddressByCurrencyMap; + final Map? manualAddressByCurrencyMap; + final AddressSource addressSource; + final String handle; + final String profileImageUrl; + final String profileName; + final String description; + final String? bip353DnsProof; + + ParsedAddress copyWith({ + Map? parsedAddressByCurrencyMap, + Map? manualAddressByCurrencyMap, + AddressSource? addressSource, + String? handle, + String? profileImageUrl, + String? profileName, + String? description, + String? bip353DnsProof, + }) { + return ParsedAddress( + parsedAddressByCurrencyMap: parsedAddressByCurrencyMap ?? this.parsedAddressByCurrencyMap, + manualAddressByCurrencyMap: manualAddressByCurrencyMap ?? this.manualAddressByCurrencyMap, + addressSource: addressSource ?? this.addressSource, + handle: handle ?? this.handle, + profileImageUrl: profileImageUrl ?? this.profileImageUrl, + profileName: profileName ?? this.profileName, + description: description ?? this.description, + bip353DnsProof: bip353DnsProof ?? this.bip353DnsProof, + ); + } +} 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 926e9f04ff..3e7f359df9 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 @@ -12,7 +12,7 @@ import 'package:cake_wallet/cake_pay/src/widgets/rounded_overlay_cards_widget.da 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/execution_state.dart'; -import 'package:cake_wallet/entities/parsed_address.dart'; +import 'package:cake_wallet/address_resolver/parsed_address.dart'; import 'package:cake_wallet/generated/i18n.dart'; import 'package:cake_wallet/routes.dart'; import 'package:cake_wallet/src/screens/base_page.dart'; @@ -595,8 +595,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 ?? '', ), @@ -661,10 +664,12 @@ class CakePayBuyCardPage extends BasePage { final displayingOutputs = outputsCopy .map((o) => o.OutputCopyWithParsedAddress( parsedAddress: ParsedAddress( - addresses: [o.address], - name: 'Cake Pay', + handle: 'Cake Pay', profileName: order?.cards.first.cardName ?? 'Cake Pay', - profileImageUrl: order?.cards.first.cardImagePath ?? '', + profileImageUrl: order?.cards.first.cardImagePath ?? '', parsedAddressByCurrencyMap: { + cakePayBuyCardViewModel.sendViewModel.selectedCryptoCurrency: + o.address, + }, ), fiatAmount: '${order?.totalReceiveAmount}', )) diff --git a/lib/core/address_validator.dart b/lib/core/address_validator.dart index a18f2feace..90e0174e18 100644 --- a/lib/core/address_validator.dart +++ b/lib/core/address_validator.dart @@ -28,7 +28,66 @@ class AddressValidator extends TextValidator { pattern: getPattern(type, isTestnet: isTestnet), length: getLength(type)); - static String getPattern(CryptoCurrency type, {bool isTestnet = false}) { + static const List reliableValidateCurrencies = [ + CryptoCurrency.xmr, + CryptoCurrency.btc, + CryptoCurrency.ltc, + CryptoCurrency.bch, + CryptoCurrency.trx, + CryptoCurrency.nano, + CryptoCurrency.banano, + CryptoCurrency.sol, + CryptoCurrency.wow, + 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 Set detectAddressBookCurrencies( + String txt, { + bool isTestnet = false, + bool includeGeneric = false + }) { + final Set matches = {}; + + final currencies = includeGeneric + ? CryptoCurrency.all + : reliableValidateCurrencies; + + for (final cur in currencies) { + final pattern = AddressValidator.getPattern(cur, isTestnet: isTestnet, ignoreZanoAlias: true); + if (pattern.isEmpty) continue; + + final lengths = AddressValidator.getLength(cur); + if (lengths != null && !lengths.contains(txt.length)) continue; + + final formatedPattern = pattern + .replaceFirst(BEFORE_REGEX, '') + .replaceFirst(AFTER_REGEX, ''); + + if (!RegExp('^$formatedPattern\$', + caseSensitive: false, multiLine: false) + .hasMatch(txt)) continue; + + matches.add(cur); + } + + return matches; + } + + static String getPattern(CryptoCurrency type, {bool isTestnet = false, bool? ignoreZanoAlias}) { var pattern = ""; if (type is Erc20Token) { pattern = '0x[0-9a-zA-Z]+'; @@ -63,9 +122,11 @@ class AddressValidator extends TextValidator { '|(ltc1q[ac-hj-np-z02-9]{40,80})' '|(${MwebAddress.regex.pattern})(\$|\s)'; case CryptoCurrency.nano: - pattern = '[0-9a-zA-Z_]+'; + pattern = '(?:nano_|xrb_)[13][13456789abcdefghijkmnopqrstuwxyz]{59}'; case CryptoCurrency.banano: - pattern = '[0-9a-zA-Z_]+'; + pattern = 'ban_[13][13456789abcdefghijkmnopqrstuwxyz]{59}'; + case CryptoCurrency.sol: + pattern = r'[1-9A-HJ-NP-Za-km-z]{43,44}'; case CryptoCurrency.usdc: case CryptoCurrency.usdcpoly: case CryptoCurrency.usdtPoly: @@ -103,7 +164,10 @@ class AddressValidator extends TextValidator { case CryptoCurrency.shib: pattern = '0x[0-9a-zA-Z]+'; case CryptoCurrency.xrp: - pattern = '[0-9a-zA-Z]{34}|[0-9a-zA-Z]{33}|X[0-9a-zA-Z]{46}'; + pattern = + '^(?:r[1-9A-HJ-NP-Za-km-z]{25,34}' // classic + '|X[1-9A-HJ-NP-Za-km-z]{46,55}' // X-address (main-net) + '|T[1-9A-HJ-NP-Za-km-z]{45,54})'; case CryptoCurrency.xhv: pattern = 'hvx|hvi|hvs[0-9a-zA-Z]+'; case CryptoCurrency.xag: @@ -123,11 +187,14 @@ class AddressValidator extends TextValidator { case CryptoCurrency.usdterc20: case CryptoCurrency.xlm: case CryptoCurrency.trx: + pattern = '^(?:T[1-9A-HJ-NP-Za-km-z]{33}|41[0-9A-Fa-f]{40}|0x41[0-9A-Fa-f]{40})'; case CryptoCurrency.dai: case CryptoCurrency.dash: case CryptoCurrency.eos: case CryptoCurrency.wow: - pattern = '[0-9a-zA-Z]+'; + pattern = r'(?:W(?:o|m|W)[1-9A-HJ-NP-Za-km-z]{94,96}' + r'|Wo[1-9A-HJ-NP-Za-km-z]{106,107})'; + break; case CryptoCurrency.bch: pattern = '(?:bitcoincash:)?(q|p)[0-9a-zA-Z]{41}' '|[13][a-km-zA-HJ-NP-Z1-9]{25,34}'; @@ -156,7 +223,9 @@ class AddressValidator extends TextValidator { case CryptoCurrency.btcln: pattern = '(lnbc|LNBC)([0-9]{1,}[a-zA-Z0-9]+)'; case CryptoCurrency.zano: - pattern = r'([1-9A-HJ-NP-Za-km-z]{90,200})|(@[\w\d.-]+)'; + pattern = ignoreZanoAlias == true + ? r'(?:Z[1-9A-HJ-NP-Za-km-z]{96}|iZ[1-9A-HJ-NP-Za-km-z]{106})' + : r'(?:Z[1-9A-HJ-NP-Za-km-z]{96}|iZ[1-9A-HJ-NP-Za-km-z]{106}|@[a-z0-9]{6,32})'; case CryptoCurrency.doge: pattern = r'^D[a-km-zA-HJ-NP-Z1-9]{25,34}'; default: @@ -179,6 +248,7 @@ class AddressValidator extends TextValidator { switch (type) { case CryptoCurrency.xmr: case CryptoCurrency.wow: + case CryptoCurrency.zano: return null; case CryptoCurrency.ada: return null; @@ -243,7 +313,7 @@ class AddressValidator extends TextValidator { case CryptoCurrency.usdcsol: return [32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44]; case CryptoCurrency.trx: - return [34]; + return [34, 42, 44]; case CryptoCurrency.usdt: return [34]; case CryptoCurrency.usdttrc20: @@ -298,7 +368,6 @@ class AddressValidator extends TextValidator { return [64]; case CryptoCurrency.btcln: case CryptoCurrency.kaspa: - case CryptoCurrency.zano: default: return null; } @@ -318,10 +387,9 @@ class AddressValidator extends TextValidator { '|(8[0-9a-zA-Z]{94})' '|([0-9a-zA-Z]{106})'; case CryptoCurrency.wow: - pattern = '(W[0-9a-zA-Z]{94})' - '|(W[0-9a-zA-Z]{94})' - '|(W[0-9a-zA-Z]{96})' - '|([0-9a-zA-Z]{106})'; + const base58 = r'[1-9A-HJ-NP-Za-km-z]'; + pattern = '(?:W(?:o|m|W)$base58{94,95}|Wo$base58{106,107})'; + break; case CryptoCurrency.btc: pattern = '${P2pkhAddress.regex.pattern}|${P2shAddress.regex.pattern}|${P2wpkhAddress.regex.pattern}|${P2trAddress.regex.pattern}|${P2wshAddress.regex.pattern}|${SilentPaymentAddress.regex.pattern}'; @@ -340,9 +408,9 @@ class AddressValidator extends TextValidator { case CryptoCurrency.bch: pattern = '(bitcoincash:)?q[0-9a-zA-Z]{41,42}'; case CryptoCurrency.sol: - pattern = '[1-9A-HJ-NP-Za-km-z]+'; + pattern = '[1-9A-HJ-NP-Za-km-z]{43,44}'; case CryptoCurrency.trx: - pattern = '(T|t)[1-9A-HJ-NP-Za-km-z]{33}'; + pattern = '^(?:T[1-9A-HJ-NP-Za-km-z]{33}|0x?41[0-9A-Fa-f]{40})'; case CryptoCurrency.zano: pattern = '([1-9A-HJ-NP-Za-km-z]{90,200})|(@[\w\d.-]+)'; default: diff --git a/lib/core/auth_service.dart b/lib/core/auth_service.dart index 378c52ec0e..f58ad7ade7 100644 --- a/lib/core/auth_service.dart +++ b/lib/core/auth_service.dart @@ -30,7 +30,6 @@ class AuthService with Store { Routes.modify2FAPage, Routes.newWallet, Routes.newWalletType, - Routes.addressBookAddContact, Routes.restoreOptions, ]; diff --git a/lib/core/yat_service.dart b/lib/core/yat_service.dart index 84aac16265..5de6f2d32f 100644 --- a/lib/core/yat_service.dart +++ b/lib/core/yat_service.dart @@ -22,34 +22,46 @@ class YatService { '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 = []; + 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 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)); + 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 yatRecords; + return records; } catch (_) { - return yatRecords; + return const []; } } } diff --git a/lib/di.dart b/lib/di.dart index 8aadf96751..1a80812c6a 100644 --- a/lib/di.dart +++ b/lib/di.dart @@ -29,9 +29,20 @@ 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/address_resolver/address_resolver_service.dart'; +import 'package:cake_wallet/address_resolver/parsed_address.dart'; import 'package:cake_wallet/exchange/provider/trocador_exchange_provider.dart'; import 'package:cake_wallet/haven/cw_haven.dart'; +import 'package:cake_wallet/src/screens/address_book/contact_refresh_page.dart'; +import 'package:cake_wallet/src/screens/address_book/contact_welcome_page.dart'; +import 'package:cake_wallet/src/screens/address_book/edit_address_page.dart'; +import 'package:cake_wallet/src/screens/address_book/edit_alias_page.dart'; +import 'package:cake_wallet/src/screens/address_book/edit_contact_page.dart'; +import 'package:cake_wallet/src/screens/address_book/contact_page.dart'; +import 'package:cake_wallet/src/screens/address_book/edit_new_contact_page.dart'; +import 'package:cake_wallet/src/screens/address_book/entities/address_edit_request.dart'; +import 'package:cake_wallet/src/screens/address_book/supported_handles_page.dart'; +import 'package:cake_wallet/src/screens/address_book/address_book_page.dart'; import 'package:cake_wallet/src/screens/dev/monero_background_sync.dart'; import 'package:cake_wallet/src/screens/dev/moneroc_cache_debug.dart'; import 'package:cake_wallet/src/screens/dev/moneroc_call_profiler.dart'; @@ -97,8 +108,6 @@ import 'package:cake_wallet/src/screens/backup/backup_page.dart'; import 'package:cake_wallet/src/screens/backup/edit_backup_password_page.dart'; import 'package:cake_wallet/src/screens/buy/buy_webview_page.dart'; import 'package:cake_wallet/src/screens/buy/webview_page.dart'; -import 'package:cake_wallet/src/screens/contact/contact_list_page.dart'; -import 'package:cake_wallet/src/screens/contact/contact_page.dart'; import 'package:cake_wallet/src/screens/dashboard/dashboard_page.dart'; import 'package:cake_wallet/src/screens/dashboard/desktop_dashboard_page.dart'; import 'package:cake_wallet/src/screens/dashboard/desktop_widgets/desktop_sidebar_wrapper.dart'; @@ -992,24 +1001,82 @@ Future setup({ getIt.registerFactory(() => WalletKeysViewModel(getIt.get())); getIt.registerFactory(() => WalletKeysPage(getIt.get())); - + getIt.registerFactory(() => AnimatedURModel(getIt.get())); getIt.registerFactoryParam, void>((Map urQr, _) => AnimatedURPage(getIt.get(), urQr: urQr)); - getIt.registerFactoryParam( - (ContactRecord? contact, _) => ContactViewModel(_contactSource, contact: contact)); + getIt.registerFactoryParam( + (req, _) => ContactViewModel(_contactSource,getIt().wallet!,getIt(), request: req,), + ); getIt.registerFactoryParam( - (CryptoCurrency? cur, _) => - ContactListViewModel(_contactSource, _walletInfoSource, cur, getIt.get())); + (cur, _) => ContactListViewModel( + _contactSource, + _walletInfoSource, + cur, + getIt(), + ), + ); - getIt.registerFactoryParam((CryptoCurrency? cur, _) => - ContactListPage(getIt.get(param1: cur), getIt.get())); + getIt.registerFactoryParam( + (cur, _) => AddressBookPage( + getIt.get(param1: cur), + getIt(), + ), + ); - getIt.registerFactoryParam( - (ContactRecord? contact, _) => ContactPage(getIt.get(param1: contact))); + getIt.registerFactoryParam( + (contact, _) => ContactWelcomePage( + contactViewModel: getIt.get( + param1: AddressEditRequest.contact(contact), + ), + ), + ); + + getIt.registerFactoryParam( + (contact, _) => ContactPage( + contactViewModel: getIt.get( + param1: AddressEditRequest.contact(contact), + ), + ), + ); + + getIt.registerFactoryParam( + (contact, cur) => ContactRefreshPage( + contactViewModel : getIt.get(param1: AddressEditRequest.contact(contact)), + currency: cur, + ), + ); + + getIt.registerFactoryParam, void>( + (list, _) => EditAddressPage(list), + ); + + getIt.registerFactory( + () => SupportedHandlesPage(contactViewModel: getIt()), + ); + + getIt.registerFactoryParam( + (contactViewModel, _) => EditContactPage(contactViewModel: contactViewModel), + ); + + getIt.registerFactoryParam( + (contactViewModel, handleKey) => EditAliasPage(contactViewModel: contactViewModel, + handleKey: handleKey), + ); + + getIt.registerFactoryParam( + (parsedAddress, record) { + return EditNewContactPage( + selectedParsedAddress: parsedAddress, + contactViewModel: getIt( + param1: AddressEditRequest.contact(record), + ), + ); + }, + ); getIt.registerFactory(() => AddressListPage(getIt.get())); @@ -1428,10 +1495,12 @@ Future setup({ getIt.registerFactory(() => YatService()); - getIt.registerFactory(() => AddressResolver( - yatService: getIt.get(), - wallet: getIt.get().wallet!, - settingsStore: getIt.get())); + getIt.registerLazySingleton( + () => AddressResolverService( + yatService: getIt(), + settingsStore: getIt(), + ), + ); getIt.registerFactoryParam( (QrViewData viewData, _) => FullscreenQRPage(qrViewData: viewData)); @@ -1556,18 +1625,18 @@ Future setup({ getIt.registerFactory(() => DevSharedPreferencesPage(getIt.get())); getIt.registerFactory(() => DevSecurePreferencesPage(getIt.get())); - + getIt.registerFactory(() => BackgroundSyncLogsViewModel()); - + getIt.registerFactory(() => DevBackgroundSyncLogsPage(getIt.get())); getIt.registerFactory(() => SocketHealthLogsViewModel()); getIt.registerFactory(() => DevSocketHealthLogsPage(getIt.get())); - + getIt.registerFactory(() => DevNetworkRequests()); getIt.registerFactory(() => StartTorPage(StartTorViewModel(),)); - + getIt.registerFactory(() => DEuroViewModel( getIt(), getIt(), diff --git a/lib/entities/bip_353_record.dart b/lib/entities/bip_353_record.dart index 9a2a50e0a5..16a6e661a9 100644 --- a/lib/entities/bip_353_record.dart +++ b/lib/entities/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'; - } - } diff --git a/lib/entities/contact.dart b/lib/entities/contact.dart index 901993f432..2601f172ad 100644 --- a/lib/entities/contact.dart +++ b/lib/entities/contact.dart @@ -1,3 +1,6 @@ +import 'dart:convert'; + +import 'package:cake_wallet/address_resolver/parsed_address.dart'; import 'package:cw_core/crypto_currency.dart'; import 'package:cw_core/hive_type_ids.dart'; import 'package:cw_core/keyable.dart'; @@ -7,11 +10,52 @@ part 'contact.g.dart'; @HiveType(typeId: Contact.typeId) class Contact extends HiveObject with Keyable { - Contact({required this.name, required this.address, CryptoCurrency? type, DateTime? lastChange}) - : lastChange = lastChange ?? DateTime.now() { - if (type != null) { - raw = type.raw; + Contact({ + required this.name, + this.address = '', + CryptoCurrency? type, + Map>> parsedByHandle = const {}, + Map> manualAddresses = const {}, + Map extraBlobs = const {}, + AddressSource source = AddressSource.notParsed, + this.handle = '', + this.imagePath = '', + this.profileName = '', + this.description = '', + DateTime? lastChange, + }) : raw = type?.raw ?? 0, + _parsedJson = _encode(parsedByHandle), + _manualJson = _encode(manualAddresses), + extraJsonBlobs = extraBlobs, + sourceRaw = source.raw, + lastChange = lastChange ?? DateTime.now(); + + factory Contact.fromParsed(ParsedAddress p, + {String? localImage, Map? customLabels}) { + final manual = >{}; + p.manualAddressByCurrencyMap?.forEach((cur, addr) { + final lbl = customLabels?[cur] ?? cur.title; + manual[cur.raw] = {lbl: addr}; + }); + + final parsed = >>{}; + if (p.parsedAddressByCurrencyMap.isNotEmpty) { + final hKey = '${p.addressSource.label}-${p.handle}'; + parsed[hKey] = { + for (final e in p.parsedAddressByCurrencyMap.entries) e.key.raw: {e.key.title: e.value} + }; } + + return Contact( + name: p.profileName.isNotEmpty ? p.profileName : p.handle, + profileName: p.profileName, + handle: p.handle, + description: p.description, + source: p.addressSource, + imagePath: localImage ?? '', + manualAddresses: manual, + parsedByHandle: parsed, + ); } static const typeId = CONTACT_TYPE_ID; @@ -24,13 +68,72 @@ class Contact extends HiveObject with Keyable { String address; @HiveField(2, defaultValue: 0) - late int raw; + int raw; @HiveField(3) DateTime lastChange; + @HiveField(4, defaultValue: '') + String _parsedJson; + + @HiveField(5, defaultValue: '') + String _manualJson; + + @HiveField(6, defaultValue: '') + String handle; + + @HiveField(7, defaultValue: '') + String imagePath; + + @HiveField(8, defaultValue: '') + String profileName; + + @HiveField(9, defaultValue: '') + String description; + + @HiveField(10, defaultValue: 0) + int sourceRaw; + + @HiveField(11, defaultValue: {}) + Map extraJsonBlobs; + + AddressSource get source => AddressSourceIndex.fromRaw(sourceRaw); + CryptoCurrency get type => CryptoCurrency.deserialize(raw: raw); + Map>> get parsedByHandle => _decodeParsed(_parsedJson); + + Map> get manualAddresses { + final decoded = _decodeManual(_manualJson); + if (decoded.isNotEmpty) return decoded; + + if (address.isNotEmpty) { + final cur = CryptoCurrency.deserialize(raw: raw); + final lbl = _legacyLabelFor(cur, name); + return { + raw: {lbl: address} + }; + } + return {}; + } + + String _legacyLabelFor(CryptoCurrency cur, String name) { + return (name.isNotEmpty) ? name : cur.title; + } + + Map> get manualByCurrency => + manualAddresses.map((k, v) => MapEntry(CryptoCurrency.deserialize(raw: k), v)); + + + + set source(AddressSource v) => sourceRaw = v.raw; + + set type(CryptoCurrency v) => raw = v.raw; + + set parsedByHandle(Map>> v) => _parsedJson = _encode(v); + + set manualAddresses(Map> v) => _manualJson = _encode(v); + @override dynamic get keyIndex => key; @@ -40,5 +143,39 @@ class Contact extends HiveObject with Keyable { @override int get hashCode => key.hashCode; - void updateCryptoCurrency({required CryptoCurrency currency}) => raw = currency.raw; + static String _encode(Object value) => jsonEncode(_stringifyKeys(value)); + + static dynamic _stringifyKeys(dynamic obj) { + if (obj is Map) { + return obj.map( + (k, v) => MapEntry(k.toString(), _stringifyKeys(v)), + ); + } + if (obj is Iterable) return obj.map(_stringifyKeys).toList(); + return obj; + } + + static Map>> _decodeParsed(String s) { + if (s.isEmpty) return {}; + final Map data = jsonDecode(s) as Map; + return data.map((handle, byCur) { + final inner = (byCur as Map).map((curRaw, lblMap) { + final int cur = int.parse(curRaw); + final labels = (lblMap as Map).cast(); + return MapEntry(cur, labels); + }); + return MapEntry(handle, inner); + }); + } + + static Map> _decodeManual(String s) { + if (s.isEmpty) return {}; + final Map data = jsonDecode(s) as Map; + return data.map((curRaw, lblMap) { + final int cur = int.parse(curRaw); + final labels = (lblMap as Map).cast(); + return MapEntry(cur, labels); + }); + } + } diff --git a/lib/entities/contact_record.dart b/lib/entities/contact_record.dart index 4b0e120ba4..d06f139418 100644 --- a/lib/entities/contact_record.dart +++ b/lib/entities/contact_record.dart @@ -1,7 +1,11 @@ +import 'dart:io'; + import 'package:cake_wallet/entities/contact.dart'; import 'package:cake_wallet/entities/contact_base.dart'; +import 'package:cake_wallet/address_resolver/parsed_address.dart'; import 'package:cake_wallet/entities/record.dart'; import 'package:cw_core/crypto_currency.dart'; +import 'package:flutter/material.dart'; import 'package:hive/hive.dart'; import 'package:mobx/mobx.dart'; @@ -10,39 +14,175 @@ part 'contact_record.g.dart'; class ContactRecord = ContactRecordBase with _$ContactRecord; abstract class ContactRecordBase extends Record with Store implements ContactBase { - ContactRecordBase(Box source, Contact original) + ContactRecordBase(Box box, Contact original) : name = original.name, - address = original.address, - type = original.type, - lastChange = original.lastChange, - super(source, original); + handle = original.handle, + profileName = original.profileName, + description = original.description, + imagePath = original.imagePath, + sourceType = original.source, + manual = ObservableMap.of(original.manualByCurrency), + parsedBlocks = ObservableMap.of({ + for (final h in original.parsedByHandle.entries) + h.key: { + for (final cur in h.value.entries) + CryptoCurrency.deserialize(raw: cur.key): Map.of(cur.value) + } + }), + super(box, original); - @override @observable - String name; + String name, handle, profileName, description, imagePath; + @observable + AddressSource sourceType; - @override @observable - String address; + ObservableMap> manual; - @override @observable - CryptoCurrency type; + ObservableMap>> parsedBlocks; + + @computed + Map> get parsedByCurrency { + final out = >{}; + parsedBlocks.forEach((_, byCur) { + byCur.forEach((cur, lbl) => out.putIfAbsent(cur, () => {})..addAll(lbl)); + }); + return out; + } + + @computed + File? get avatarFile => imagePath.isEmpty ? null : File(imagePath); + + @computed + ImageProvider get avatarProvider => (avatarFile?.existsSync() ?? false) + ? FileImage(avatarFile!) + : const AssetImage('assets/images/profile.png'); - DateTime? lastChange; + @override + void toBind(Contact c) { + reaction((_) => name, (v) => c.name = v); + reaction((_) => handle, (v) => c.handle = v); + reaction((_) => profileName, (v) => c.profileName = v); + reaction((_) => description, (v) => c.description = v); + reaction((_) => imagePath, (v) => c.imagePath = v); + reaction((_) => sourceType, (v) => c.source = v); + } @override - void toBind(Contact original) { - reaction((_) => name, (String name) => original.name = name); - reaction((_) => address, (String address) => original.address = address); - reaction((_) => type, - (CryptoCurrency currency) => original.updateCryptoCurrency(currency: currency)); + void fromBind(Contact c) { + name = c.name; + handle = c.handle; + profileName = c.profileName; + description = c.description; + imagePath = c.imagePath; + sourceType = c.source; + } + + @action + void setManualAddress(CryptoCurrency cur, String label, String addr) { + manual.putIfAbsent(cur, () => {})[label] = addr; + _flushManual(); + } + + @action + void removeManualAddress(CryptoCurrency cur, String label) { + final map = manual[cur]; + if (map == null) return; + map.remove(label); + if (map.isEmpty) manual.remove(cur); + _flushManual(); + } + + @action + void setParsedAddress(String blockKey, CryptoCurrency cur, String label, String addr) { + final block = parsedBlocks.putIfAbsent(blockKey, () => {}); + block.putIfAbsent(cur, () => {})[label] = addr; + parsedBlocks[blockKey] = {for (final e in block.entries) e.key: Map.of(e.value)}; + _flushParsed(); + } + + @action + void removeParsedAddress(String blockKey, CryptoCurrency? cur, String? label) { + final block = parsedBlocks[blockKey]; + if (block == null) return; + + if (cur == null) { + parsedBlocks.remove(blockKey); + _flushParsed(); + return; + } + + final map = block[cur]; + if (map == null) return; + + if (label == null) { + block.remove(cur); + } else { + map.remove(label); + if (map.isEmpty) block.remove(cur); + } + + if (block.isEmpty) { + parsedBlocks.remove(blockKey); + } else { + parsedBlocks[blockKey] = {for (final e in block.entries) e.key: Map.of(e.value)}; + } + + _flushParsed(); + } + + void _flushManual() { + original + ..manualAddresses = { + for (final e in manual.entries) e.key.raw: Map.of(e.value) + } + ..lastChange = DateTime.now(); + } + + void _flushParsed() { + original + ..parsedByHandle = { + for (final h in parsedBlocks.entries) + h.key: {for (final cur in h.value.entries) cur.key.raw: Map.of(cur.value)} + } + ..lastChange = DateTime.now(); } + @action + void replaceParsedBlock(String handleKey, Map> newBlock) { + parsedBlocks[handleKey] = { + for (final e in newBlock.entries) e.key: Map.of(e.value) + }; + + original.parsedByHandle = { + for (final h in parsedBlocks.entries) + h.key: {for (final cur in h.value.entries) cur.key.raw: Map.of(cur.value)} + }; + original.lastChange = DateTime.now(); + } + + @override + String address = ''; @override - void fromBind(Contact original) { - name = original.name; - address = original.address; - type = original.type; + CryptoCurrency type = CryptoCurrency.btc; + + List get allParsedAddresses { + final addresses = {}; + for (final byCur in parsedBlocks.values) { + for (final byAddr in byCur.values) { + addresses.addAll(byAddr.values); + } + } + return addresses.toList(); + } + + List get allManualAddresses { + final addresses = {}; + for (final byAddr in manual.values) { + addresses.addAll(byAddr.values); + } + return addresses.toList(); } + } diff --git a/lib/entities/ens_record.dart b/lib/entities/ens_record.dart index 78b0f4178b..e25ed6dbe6 100644 --- a/lib/entities/ens_record.dart +++ b/lib/entities/ens_record.dart @@ -1,5 +1,6 @@ import 'package:cake_wallet/ethereum/ethereum.dart'; import 'package:cake_wallet/polygon/polygon.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'; @@ -8,50 +9,50 @@ import 'package:ens_dart/ens_dart.dart'; import 'package:web3dart/web3dart.dart'; class EnsRecord { - - static Future fetchEnsAddress(String name, {WalletBase? wallet}) async { - + static Future fetchEnsAddress( + String name, + CryptoCurrency cur, { + WalletBase? wallet, + }) async { Web3Client? _client; - if (wallet != null && wallet.type == WalletType.ethereum) { - _client = ethereum!.getWeb3Client(wallet); - } - - if (wallet != null && wallet.type == WalletType.polygon) { - _client = polygon!.getWeb3Client(wallet); + if (wallet?.type == WalletType.ethereum) { + _client = ethereum!.getWeb3Client(wallet!); + } else if (wallet?.type == WalletType.polygon) { + _client = polygon!.getWeb3Client(wallet!); } - if (_client == null) { - late final client = ProxyWrapper().getHttpIOClient(); + _client ??= Web3Client( + 'https://ethereum-rpc.publicnode.com', + ProxyWrapper().getHttpIOClient(), + ); + + final ens = Ens(client: _client); + final coinType = getEnsCoinType(cur); - _client = Web3Client("https://ethereum-rpc.publicnode.com", client); + if (coinType == null) { + printV('Unsupported currency for ENS: $cur'); + return ''; } 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: - default: - return (await ens.withName(name).getAddress()).hex; - } + if (coinType == CoinType.ETH || coinType == CoinType.MATIC) { + return (await ens.withName(name).getAddress()).hex; + } else { + return await ens.withName(name).getCoinAddress(coinType); } - - final addr = await ens.withName(name).getAddress(); - return addr.hex; } catch (e) { printV(e); - return ""; + 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, + }; } diff --git a/lib/entities/fio_address_provider.dart b/lib/entities/fio_address_provider.dart index dcfd79c96d..d43ebfe4bb 100644 --- a/lib/entities/fio_address_provider.dart +++ b/lib/entities/fio_address_provider.dart @@ -1,5 +1,6 @@ import 'dart:convert'; +import 'package:cw_core/utils/print_verbose.dart'; import 'package:cw_core/utils/proxy_wrapper.dart'; class FioAddressProvider { @@ -23,14 +24,13 @@ class FioAddressProvider { 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 { + static Future getPubAddress(String fioAddress, String token) async { final headers = {'Content-Type': 'application/json'}; final body = { "fio_address": fioAddress, @@ -45,21 +45,28 @@ class FioAddressProvider { body: json.encode(body), ); - + final responseJSON = json.decode(response.body) as Map; + 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'); + printV('${error}\n$message'); + return null; } if (response.statusCode != 200) { - throw Exception('Unexpected response http status: ${response.statusCode}'); + final String message = responseJSON['message'] as String? ?? 'Unknown error'; + + printV('Error fetching public address for token $token: $message'); + return null; } - final responseJSON = json.decode(response.body) as Map; - final String pubAddress = responseJSON['public_address'] as String; + final String pubAddress = responseJSON['public_address'] as String? ?? ''; + + if (pubAddress.isNotEmpty) { + return pubAddress; + } - return pubAddress; + return null; } } diff --git a/lib/entities/fs_migration.dart b/lib/entities/fs_migration.dart index a7604f4f14..ee216abca0 100644 --- a/lib/entities/fs_migration.dart +++ b/lib/entities/fs_migration.dart @@ -428,11 +428,20 @@ Future ios_migrate_address_book(Box contactSource) async { json.decode(addressBookJSON.readAsStringSync()) as List; final contacts = addresses.map((dynamic item) { final _item = item as Map; - final type = _item["type"] as String; - final address = _item["address"] as String; - final name = _item["name"] as String; - - return Contact(address: address, name: name, type: CryptoCurrency.fromString(type)); + final type = _item['type'] as String? ?? ''; + final address = _item['address'] as String? ?? ''; + final name = _item['name'] as String? ?? ''; + + final cur = CryptoCurrency.fromString(type); + final lbl = name.isNotEmpty ? name : cur.title; + + return Contact( + name: name, + manualAddresses: { + cur.raw: { lbl : address } + }, + parsedByHandle: const {}, + ); }); await contactSource.addAll(contacts); diff --git a/lib/entities/openalias_record.dart b/lib/entities/openalias_record.dart index b2af2e53d2..4fe4b6a89f 100644 --- a/lib/entities/openalias_record.dart +++ b/lib/entities/openalias_record.dart @@ -37,53 +37,33 @@ class OpenaliasRecord { 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; - } + }) { + 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); -} + } } diff --git a/lib/entities/parse_address_from_domain.dart b/lib/entities/parse_address_from_domain.dart deleted file mode 100644 index b2621a688e..0000000000 --- a/lib/entities/parse_address_from_domain.dart +++ /dev/null @@ -1,405 +0,0 @@ -import 'package:cake_wallet/core/address_validator.dart'; -import 'package:cake_wallet/core/yat_service.dart'; -import 'package:cake_wallet/entities/ens_record.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/emoji_string_extension.dart'; -import 'package:cake_wallet/entities/wellknown_record.dart'; -import 'package:cake_wallet/entities/zano_alias.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/wallet_base.dart'; -import 'package:cw_core/wallet_type.dart'; -import 'package:cake_wallet/entities/fio_address_provider.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", - "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" - ]; - - static String? extractAddressByType( - {required String raw, - required CryptoCurrency type, - bool requireSurroundingWhitespaces = true}) { - var addressPattern = AddressValidator.getAddressFromStringPattern(type); - - if (addressPattern == null) { - throw Exception('Unexpected token: $type for getAddressFromStringPattern'); - } - - 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 ''; - }); - } - - 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); - } - - Future resolve(BuildContext context, String text, CryptoCurrency currency) async { - final ticker = currency.title; - try { - // 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); - } - - final pinnedTweet = twitterUser.pinnedTweet?.text; - if (pinnedTweet != null) { - final addressFromPinnedTweet = extractAddressByType( - raw: pinnedTweet, - type: CryptoCurrency.fromString(ticker, walletCurrency: wallet.currency)); - if (addressFromPinnedTweet != null) { - return ParsedAddress.fetchTwitterAddress( - address: addressFromPinnedTweet, - name: text, - profileImageUrl: twitterUser.profileImageUrl, - profileName: twitterUser.name); - } - } - } - } - - // 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) { - String? addressFromBio = extractAddressByType(raw: mastodonUser.note, type: currency); - - 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) => item.content).join('\n'); - String? addressFromPinnedPost = - extractAddressByType(raw: userPinnedPostsText, type: currency); - - 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 (!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); - } - } - } - - 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]); - } -} diff --git a/lib/entities/parsed_address.dart b/lib/entities/parsed_address.dart deleted file mode 100644 index 74acab80a7..0000000000 --- a/lib/entities/parsed_address.dart +++ /dev/null @@ -1,185 +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, - bip353 -} - -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.fetchWellKnownAddress({required String address, required String name}) { - return ParsedAddress( - addresses: [address], - name: name, - parseFrom: ParseFrom.wellKnown, - ); - } - - 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 36022a929a..85f68f1d9b 100644 --- a/lib/entities/preferences_key.dart +++ b/lib/entities/preferences_key.dart @@ -86,6 +86,10 @@ class PreferencesKey { static const lookupsOpenAlias = 'looks_up_open_alias'; static const lookupsENS = 'looks_up_ens'; 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 usePayjoin = 'use_payjoin'; static const showPayjoinCard = 'show_payjoin_card'; static const showCameraConsent = 'show_camera_consent'; diff --git a/lib/entities/wellknown_record.dart b/lib/entities/wellknown_record.dart index 6dd94440b3..2333d21923 100644 --- a/lib/entities/wellknown_record.dart +++ b/lib/entities/wellknown_record.dart @@ -7,87 +7,72 @@ import 'package:cw_core/utils/proxy_wrapper.dart'; class WellKnownRecord { WellKnownRecord({ required this.address, - required this.name, + required this.alias, + required this.imageUrl, + required this.title, }); - 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; + 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; } - // 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]; - } + 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); - if (name.isEmpty) { - name = "_"; - } + final uri = Uri.https( + domain, + '/.well-known/$location.json', + {'names': encoded}, + ); - // 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"}, + try { + final res = await ProxyWrapper().get( + clearnetUri: uri, + headers: {'Accept': 'application/json'}, ); - if (response.statusCode != 200) { + 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 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; + 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("error checking well-known username: $e"); + printV('well-known: network / JSON error → $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 index 40e677a853..99034aa51e 100644 --- a/lib/entities/yat_record.dart +++ b/lib/entities/yat_record.dart @@ -1,13 +1,19 @@ +import 'package:cake_wallet/core/yat_service.dart'; + class YatRecord { YatRecord({ - required this.category, required this.address, + required this.tag, }); - YatRecord.fromJson(Map json) - : address = json['address'] as String, - category = json['category'] as String; + 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; - String category; - String address; + @override + String toString() => 'YatRecord(tag: $tag, address: $address)'; } diff --git a/lib/exchange/provider/thorchain_exchange.provider.dart b/lib/exchange/provider/thorchain_exchange.provider.dart index 3557a847b1..c9d0ba0bdb 100644 --- a/lib/exchange/provider/thorchain_exchange.provider.dart +++ b/lib/exchange/provider/thorchain_exchange.provider.dart @@ -221,30 +221,31 @@ class ThorChainExchangeProvider extends ExchangeProvider { ); } - static Future?>? lookupAddressByName(String name) async { + static Future?> lookupAddressByName(String name) async { final uri = Uri.https(_baseURL, '$_nameLookUpPath$name'); final response = await ProxyWrapper().get(clearnetUri: uri); - - if (response.statusCode != 200) { - return null; - } + + if (response.statusCode != 200) return null; final body = json.decode(response.body) as Map; final entries = body['entries'] as List?; - if (entries == null || entries.isEmpty) { - return null; - } + if (entries == null || entries.isEmpty) return null; + + final Map map = { + for (final e in entries) + if (e['chain'] != null && e['address'] != null) + (e['chain'] as String).toUpperCase(): e['address'] as String, + }; - Map chainToAddressMap = {}; + final owner = body['owner'] as String?; + if (owner != null && owner.isNotEmpty) { + map.putIfAbsent('THOR', () => owner); - for (final entry in entries) { - final chain = entry['chain'] as String; - final address = entry['address'] as String; - chainToAddressMap[chain] = address; + map['RUNE'] = owner; } - return chainToAddressMap; + return map.isNotEmpty ? map : null; } Future> _getSwapQuote(Map params) async { diff --git a/lib/nostr/nostr_api.dart b/lib/nostr/nostr_api.dart index be59f0ebaf..3e976ac697 100644 --- a/lib/nostr/nostr_api.dart +++ b/lib/nostr/nostr_api.dart @@ -1,143 +1,127 @@ +import 'dart:async'; 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'; +import 'dart:async' show Completer, TimeoutException, runZonedGuarded; class NostrProfileHandler { static final relayToDomainMap = { 'relay.snort.social': 'snort.social', }; - static Nip05 _nip05 = Nip05(); + static final 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); - } + 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( - 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; - } + 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; } } - 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; - } + // 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 { - 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; - } + // 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); + }); - relay.close(); - return null; - } catch (e) { - printV('[!] Error with relay $relayUrl: $e'); - return null; - } + return result.future; } - 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 _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 nip05Address) { - var parts = nip05Address.split('@'); - return parts.length == 2 ? parts[1] : ''; - } + static String _extractDomain(String nip05) => nip05.split('@').last; - static String _getDomainFromRelayUrl(String relayUrl) { + static String _getDomainFromRelayUrl(String url) { try { - var uri = Uri.parse(relayUrl); - return uri.host; - } catch (e) { - printV('Error parsing URL: $e'); + return Uri.parse(url).host; + } catch (_) { 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/router.dart b/lib/router.dart index ad43badf24..0cffccd2f6 100644 --- a/lib/router.dart +++ b/lib/router.dart @@ -24,8 +24,7 @@ import 'package:cake_wallet/cake_pay/cake_pay.dart'; import 'package:cake_wallet/src/screens/connect_device/connect_device_page.dart'; import 'package:cake_wallet/src/screens/connect_device/monero_hardware_wallet_options_page.dart'; import 'package:cake_wallet/src/screens/connect_device/select_hardware_wallet_account_page.dart'; -import 'package:cake_wallet/src/screens/contact/contact_list_page.dart'; -import 'package:cake_wallet/src/screens/contact/contact_page.dart'; +import 'package:cake_wallet/src/screens/address_book/address_book_page.dart'; import 'package:cake_wallet/src/screens/dashboard/dashboard_page.dart'; import 'package:cake_wallet/src/screens/dashboard/desktop_widgets/desktop_dashboard_actions.dart'; import 'package:cake_wallet/src/screens/dashboard/edit_token_page.dart'; @@ -149,6 +148,7 @@ import 'package:cw_core/wallet_type.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'address_resolver/parsed_address.dart'; import 'src/screens/buy/buy_sell_page.dart'; import 'src/screens/dashboard/pages/nft_import_page.dart'; @@ -579,22 +579,17 @@ Route createRoute(RouteSettings settings) { case Routes.addressBook: return handleRouteWithPlatformAwareness( - (context) => getIt.get(), + (context) => getIt.get(), ); case Routes.pickerAddressBook: final selectedCurrency = settings.arguments as CryptoCurrency?; return MaterialPageRoute( - builder: (_) => getIt.get(param1: selectedCurrency)); + builder: (_) => getIt.get(param1: selectedCurrency)); case Routes.pickerWalletAddress: return MaterialPageRoute(builder: (_) => getIt.get()); - case Routes.addressBookAddContact: - return handleRouteWithPlatformAwareness( - (context) => getIt.get(param1: settings.arguments as ContactRecord?), - ); - case Routes.showKeys: return handleRouteWithPlatformAwareness( (context) => getIt.get(), diff --git a/lib/routes.dart b/lib/routes.dart index 3f9050de21..21348f8f2e 100644 --- a/lib/routes.dart +++ b/lib/routes.dart @@ -35,7 +35,14 @@ class Routes { static const addressBook = '/address_book'; static const pickerAddressBook = '/picker_address_book'; static const pickerWalletAddress = '/picker_wallet_address'; - static const addressBookAddContact = '/address_book_add_contact'; + static const contactWelcomePage = '/contact_welcome_page'; + static const supportedHandlesPage = '/supported_handles_page'; + static const contactPage = '/contact_page'; + static const contactRefreshPage = '/contact_refresh_page'; + static const editContactPage = '/edit_contact_page'; + static const editAliasPage = '/edit_alias_page'; + static const editAddressPage = '/edit_address_page'; + static const editNewContactPage = '/edit_new_contact_page'; static const showKeys = '/show_keys'; static const exchangeConfirm = '/exchange_confirm'; static const tradeHistory = '/trade_history'; diff --git a/lib/src/screens/address_book/address_book_page.dart b/lib/src/screens/address_book/address_book_page.dart new file mode 100644 index 0000000000..fce70c89f8 --- /dev/null +++ b/lib/src/screens/address_book/address_book_page.dart @@ -0,0 +1,166 @@ +import 'package:cake_wallet/core/auth_service.dart'; +import 'package:cake_wallet/di.dart'; +import 'package:cake_wallet/address_resolver/address_resolver_service.dart'; +import 'package:cake_wallet/generated/i18n.dart'; +import 'package:cake_wallet/src/screens/address_book/widgets/contact_list_tab_widget.dart'; +import 'package:cake_wallet/src/screens/address_book/widgets/wallet_contacts_list_tab_widget.dart'; +import 'package:cake_wallet/src/screens/base_page.dart'; +import 'package:cake_wallet/src/widgets/bottom_sheet/contact_bottom_sheet_widget.dart'; +import 'package:cake_wallet/view_model/contact_list/contact_list_view_model.dart'; +import 'package:flutter/material.dart'; + +class AddressBookPage extends BasePage { + AddressBookPage(this.contactListViewModel, this.authService); + + final ContactListViewModel contactListViewModel; + final AuthService authService; + + @override + String get title => S.current.address_book; + + @override + Widget? trailing(BuildContext context) { + return MergeSemantics( + child: Container( + width: 32.0, + height: 32.0, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Theme.of(context).colorScheme.surfaceContainerHighest, + ), + child: Semantics( + label: S.of(context).add_contact, + button: true, + child: Stack( + alignment: Alignment.center, + children: [ + Icon( + Icons.add, + color: Theme.of(context).colorScheme.onSurface, + size: 22.0, + ), + ButtonTheme( + minWidth: 32.0, + height: 32.0, + child: TextButton( + // FIX-ME: Style + //shape: CircleBorder(), + onPressed: () async { + await _showAddressBookBottomSheet( + context: context, contactListViewModel: contactListViewModel); + }, + child: Offstage(), + ), + ) + ], + ), + ), + ), + ); + } + + @override + Widget body(BuildContext context) => ContactPageBody(contactListViewModel: contactListViewModel); +} + +class ContactPageBody extends StatefulWidget { + const ContactPageBody({required this.contactListViewModel}); + + final ContactListViewModel contactListViewModel; + + @override + State createState() => _ContactPageBodyState(); +} + +class _ContactPageBodyState extends State with SingleTickerProviderStateMixin { + late TabController _tabController; + late ContactListViewModel contactListViewModel; + + @override + void initState() { + super.initState(); + _tabController = TabController(length: 2, vsync: this); + contactListViewModel = widget.contactListViewModel; + } + + @override + void dispose() { + _tabController.dispose(); + contactListViewModel.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(), + child: Column( + children: [ + Padding( + padding: const EdgeInsets.only(left: 24, right: 24, bottom: 8), + child: Align( + alignment: Alignment.centerLeft, + child: TabBar( + controller: _tabController, + splashFactory: NoSplash.splashFactory, + indicatorSize: TabBarIndicatorSize.label, + isScrollable: true, + labelStyle: Theme.of(context).textTheme.bodyLarge!.copyWith( + fontSize: 20, + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.primary, + ), + unselectedLabelStyle: Theme.of(context).textTheme.bodyLarge!.copyWith( + fontSize: 20, + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + indicatorColor: Theme.of(context).colorScheme.primary, + indicatorPadding: EdgeInsets.zero, + labelPadding: EdgeInsets.only(right: 24), + tabAlignment: TabAlignment.start, + dividerColor: Colors.transparent, + padding: EdgeInsets.zero, + tabs: [ + Tab(text: S.of(context).wallets), + Tab(text: S.of(context).contact_list_contacts), + ], + ), + ), + ), + Expanded( + child: TabBarView( + controller: _tabController, + children: [ + WalletContactsListTabWidget( + walletContacts: contactListViewModel.walletContactsToShow, + isEditable: contactListViewModel.isEditable, + ), + ContactListBody( + contactListViewModel: contactListViewModel, + tabController: _tabController, + ), + ], + ), + ), + ], + ), + ); + } +} + +Future _showAddressBookBottomSheet( + {required BuildContext context, + required ContactListViewModel contactListViewModel, + String? initialRoute, + Object? initialArgs}) async { + await showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (BuildContext bottomSheetContext) { + return AddressBookBottomSheet( + initialRoute: initialRoute, + initialArgs: initialArgs, + ); + }); +} diff --git a/lib/src/screens/address_book/contact_page.dart b/lib/src/screens/address_book/contact_page.dart new file mode 100644 index 0000000000..19b19cb27a --- /dev/null +++ b/lib/src/screens/address_book/contact_page.dart @@ -0,0 +1,216 @@ +import 'package:cake_wallet/core/execution_state.dart'; +import 'package:cake_wallet/di.dart'; +import 'package:cake_wallet/address_resolver/address_resolver_service.dart'; +import 'package:cake_wallet/address_resolver/parsed_address.dart'; +import 'package:cake_wallet/routes.dart'; +import 'package:cake_wallet/src/screens/address_book/sheet_page.dart'; +import 'package:cake_wallet/src/screens/address_book/widgets/addresses_expansion_tile_widget.dart'; +import 'package:cake_wallet/src/widgets/rounded_icon_button.dart'; +import 'package:cake_wallet/utils/image_utill.dart'; +import 'package:cake_wallet/view_model/contact_list/contact_view_model.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_mobx/flutter_mobx.dart'; + +class ContactPage extends SheetPage { + ContactPage({required this.contactViewModel}); + + final ContactViewModel contactViewModel; + + @override + Widget? leading(BuildContext context) { + return RoundedIconButton( + icon: Icons.refresh_outlined, onPressed: () async => await contactViewModel.refresh()); + } + + @override + Widget middle(BuildContext context) { + return Observer( + builder: (_) => Center( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(5), + child: Image( + width: 30, height: 30, image: contactViewModel.avatar, fit: BoxFit.cover)), + const SizedBox(width: 12), + Text( + contactViewModel.name, + style: Theme.of(context) + .textTheme + .titleLarge + ?.copyWith(fontSize: 18, fontWeight: FontWeight.w600), + ), + ], + ), + ), + ); + } + + @override + Widget trailing(BuildContext context) { + return SizedBox( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + RoundedIconButton( + icon: Icons.add, + onPressed: () { + Navigator.pushNamed( + context, + Routes.contactWelcomePage, + arguments: contactViewModel.record, + ); + }), + const SizedBox(width: 8), + RoundedIconButton( + icon: Icons.edit, + onPressed: () { + Navigator.pushNamed( + context, + Routes.editContactPage, + arguments: contactViewModel, + ); + }), + ], + ), + ); + } + + @override + Widget body(BuildContext context) => Observer(builder: (_) { + const iconSize = 24.0; + const iconOffset = 12.0; + + return ConstrainedBox( + constraints: BoxConstraints( + minHeight: MediaQuery.of(context).size.height * 0.35, + maxHeight: MediaQuery.of(context).size.height * 0.7, + ), + child: Scrollbar( + thumbVisibility: true, + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (contactViewModel.manual.isNotEmpty) + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: ContactAddressesExpansionTile( + key: ValueKey(contactViewModel.name), + title: const Text('Manual Addresses'), + initiallyExpanded: true, + fillColor: + Theme.of(context).colorScheme.surfaceContainer, + addressByCurrency: contactViewModel.manual, + onCopyPressed: (addr) => + Clipboard.setData(ClipboardData(text: addr)), + onEditPressed: (cur, lbl) => Navigator.pushNamed( + context, + Routes.editAddressPage, + arguments: [contactViewModel, cur, lbl], + ), + ), + ), + ...contactViewModel.parsedBlocks.entries.map((entry) { + final handleKey = entry.key; + final srcLabel = handleKey.split('-').first; + final byCurrency = entry.value; + final src = + AddressSourceNameParser.fromLabel(srcLabel); + + final currencies = byCurrency.keys.toList(); + + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Container( + padding: + const EdgeInsets.symmetric(horizontal: 4, vertical: 6), + decoration: BoxDecoration( + color: Theme.of(context) + .colorScheme + .surfaceContainer, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + const SizedBox(width: 8), + ImageUtil.getImageFromPath( + imagePath: src.iconPath, + height: 24, + width: 24, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + handleKey, + overflow: TextOverflow.ellipsis, + style: Theme.of(context) + .textTheme + .labelSmall + ?.copyWith( + fontSize: 12, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, + ), + ), + ), + contactViewModel.state is IsExecutingState + ? const SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator( + strokeWidth: 2, + ), + ) + : SizedBox( + width: iconSize + + iconOffset * (currencies.length - 1), + height: iconSize, + child: Stack( + children: [ + for (int i = 0; + i < currencies.length; + ++i) + Positioned( + left: i * iconOffset, + child: CircleAvatar( + radius: iconSize / 2, + backgroundColor: Colors.transparent, + child: ImageUtil.getImageFromPath( + imagePath: + currencies[i].iconPath ?? '', + height: iconSize - 4, + width: iconSize - 4, + ), + ), + ), + ], + ), + ), + const SizedBox(width: 8), + RoundedIconButton( + icon: Icons.edit, + onPressed: () => Navigator.pushNamed( + context, + Routes.editAliasPage, + arguments: [contactViewModel, handleKey], + ), + ), + const SizedBox(width: 8), + ], + ), + ), + ); + }).toList(), + const SizedBox(height: 16), + ], + ), + ), + ), + ); + }); +} diff --git a/lib/src/screens/address_book/contact_refresh_page.dart b/lib/src/screens/address_book/contact_refresh_page.dart new file mode 100644 index 0000000000..6d8b3e994d --- /dev/null +++ b/lib/src/screens/address_book/contact_refresh_page.dart @@ -0,0 +1,219 @@ +import 'package:cake_wallet/core/execution_state.dart'; +import 'package:cake_wallet/address_resolver/parsed_address.dart'; +import 'package:cake_wallet/generated/i18n.dart'; +import 'package:cake_wallet/src/screens/address_book/sheet_page.dart'; +import 'package:cake_wallet/src/screens/address_book/widgets/addresses_expansion_tile_widget.dart'; +import 'package:cake_wallet/src/widgets/primary_button.dart'; +import 'package:cake_wallet/utils/image_utill.dart'; +import 'package:cake_wallet/view_model/contact_list/contact_view_model.dart'; +import 'package:cw_core/crypto_currency.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_mobx/flutter_mobx.dart'; + +class ContactRefreshPage extends SheetPage { + ContactRefreshPage({required this.currency, required this.contactViewModel}) { + contactViewModel.refresh(); + } + + final CryptoCurrency currency; + final ContactViewModel contactViewModel; + + @override + Widget middle(BuildContext context) => Row( + mainAxisSize: MainAxisSize.min, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(5), + child: + Image(image: contactViewModel.avatar, width: 24, height: 24, fit: BoxFit.cover)), + const SizedBox(width: 12), + Text(contactViewModel.name, + style: Theme.of(context) + .textTheme + .titleLarge + ?.copyWith(fontSize: 18, fontWeight: FontWeight.w600), + overflow: TextOverflow.ellipsis) + ], + ); + + @override + Widget body(BuildContext context) { + return Observer(builder: (_) { + if (contactViewModel.state is IsExecutingState) { + return const _ProgressCard(); + } + + final hasCurrency = + contactViewModel.parsedBlocks.values.any((m) => m[currency]?.isNotEmpty == true) || + contactViewModel.manual[currency]?.isNotEmpty == true; + + if (!hasCurrency) { + return _ErrorCard(currency: currency); + } + + return _FilteredAddressesCard( + contactViewModel: contactViewModel, + currency: currency, + ); + }); + } +} + +class _ProgressCard extends StatelessWidget { + const _ProgressCard(); + + @override + Widget build(BuildContext context) => SizedBox( + height: MediaQuery.of(context).size.height * .35, + width: double.infinity, + child: Column( + children: [ + const Spacer(), + Text( + S.of(context).checking_for_alias_changes, + style: Theme.of(context).textTheme.bodyLarge, + ), + const Spacer(flex: 2), + const SizedBox(width: 60, height: 60, child: CircularProgressIndicator()), + const Spacer(flex: 3), + ], + ), + ); +} + +class _ErrorCard extends StatelessWidget { + const _ErrorCard({required this.currency}); + + final CryptoCurrency currency; + + @override + Widget build(BuildContext context) => SizedBox( + height: MediaQuery.of(context).size.height * .35, + child: Column( + children: [ + Spacer(), + Text(S.of(context).error, style: Theme.of(context).textTheme.titleLarge), + const SizedBox(height: 16), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Text( + S.of(context).contact_no_longer_has_an_address_assigne, + style: Theme.of(context).textTheme.bodyLarge, + textAlign: TextAlign.center, + ), + ), + Spacer(flex: 2), + Padding( + padding: const EdgeInsets.only(top: 8, bottom: 16), + child: LoadingPrimaryButton( + text: S.of(context).ok, + width: 150, + height: 40, + color: Theme.of(context).colorScheme.primary, + textColor: Theme.of(context).colorScheme.onPrimary, + isLoading: false, + isDisabled: false, + onPressed: () { + if (context.mounted) Navigator.of(context, rootNavigator: true).pop(); + }, + ), + ), + ], + ), + ); +} + +class _FilteredAddressesCard extends StatelessWidget { + const _FilteredAddressesCard({ + required this.contactViewModel, + required this.currency, + }); + + final ContactViewModel contactViewModel; + final CryptoCurrency currency; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Observer(builder: (_) { + final tiles = []; + + final manualMap = contactViewModel.manual[currency]; + if (manualMap != null && manualMap.isNotEmpty) { + tiles.add( + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: ContactAddressesExpansionTile( + key: ValueKey('${contactViewModel.name}-manual-$currency'), + title: const Text('Manual Addresses'), + initiallyExpanded: true, + shouldTruncateContent: false, + fillColor: theme.colorScheme.surfaceContainer, + addressByCurrency: {currency: manualMap}, + onAddressPressed: (address) { + if (context.mounted) + Navigator.of(context, rootNavigator: true) + .pop((contactViewModel.record, address)); + }, + ), + ), + ); + } + + contactViewModel.parsedBlocks.forEach((handle, byCurrency) { + final curMap = byCurrency[currency]; + if (curMap == null || curMap.isEmpty) return; + final srcLabel = handle.split('-').first; + final src = AddressSourceNameParser.fromLabel(srcLabel); + + tiles.add( + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: ContactAddressesExpansionTile( + key: ValueKey('${contactViewModel.name}-$handle-$currency'), + initiallyExpanded: true, + shouldTruncateContent: false, + fillColor: theme.colorScheme.surfaceContainer, + addressByCurrency: {currency: curMap}, + onAddressPressed: (address) { + if (context.mounted) + Navigator.of(context, rootNavigator: true) + .pop((contactViewModel.record, address)); + }, + title: Row( + children: [ + ImageUtil.getImageFromPath( + imagePath: src.iconPath, + height: 24, + width: 24, + ), + const SizedBox(width: 6), + Expanded( + child: Text( + handle, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.labelSmall?.copyWith( + fontSize: 12, + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + ], + ), + ), + ), + ); + }); + tiles.add(const SizedBox(height: 16)); + + return SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: tiles, + ), + ); + }); + } +} diff --git a/lib/src/screens/address_book/contact_welcome_page.dart b/lib/src/screens/address_book/contact_welcome_page.dart new file mode 100644 index 0000000000..c32e9d296c --- /dev/null +++ b/lib/src/screens/address_book/contact_welcome_page.dart @@ -0,0 +1,567 @@ +import 'dart:async'; + +import 'package:cake_wallet/core/address_validator.dart'; +import 'package:cake_wallet/di.dart'; +import 'package:cake_wallet/address_resolver/address_resolver_service.dart'; +import 'package:cake_wallet/address_resolver/parsed_address.dart'; +import 'package:cake_wallet/generated/i18n.dart'; +import 'package:cake_wallet/routes.dart'; +import 'package:cake_wallet/src/screens/address_book/sheet_page.dart'; +import 'package:cake_wallet/src/widgets/rounded_icon_button.dart'; +import 'package:cake_wallet/src/screens/address_book/widgets/standard_text_form_field_widget.dart'; +import 'package:cake_wallet/src/widgets/primary_button.dart'; +import 'package:cake_wallet/themes/core/material_base_theme.dart'; +import 'package:cake_wallet/utils/image_utill.dart'; +import 'package:cake_wallet/view_model/contact_list/contact_view_model.dart'; +import 'package:cw_core/crypto_currency.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +class ContactWelcomePage extends SheetPage { + ContactWelcomePage({required this.contactViewModel}); + + final ContactViewModel contactViewModel; + + @override + Widget body(BuildContext context) => _WelcomeBody( + currentTheme: currentTheme, + contactViewModel: contactViewModel, + ); +} + +class _WelcomeBody extends StatefulWidget { + const _WelcomeBody( + {required this.currentTheme, required this.contactViewModel}); + + final MaterialThemeBase currentTheme; + final ContactViewModel contactViewModel; + + @override + State<_WelcomeBody> createState() => _WelcomeBodyState(contactViewModel); +} + +class _PlainTextSelection { + const _PlainTextSelection(this.text); + + final String text; +} + +class _WelcomeBodyState extends State<_WelcomeBody> { + _WelcomeBodyState(this.contactViewModel); + + final ContactViewModel contactViewModel; + final _controller = TextEditingController(); + final _focusNode = FocusNode(); + final _debouncer = const Duration(milliseconds: 700); + final _searchIndicatorDebouncer = const Duration(milliseconds: 600); + final resolver = getIt(); + + Timer? _debounce; + Timer? _searchIndicatorDebounce; + + List _results = []; + bool _isSearching = false; + ParsedAddress? _selectedHandle; + _PlainTextSelection? _plainSelected; + String _typedText = ''; + final _inputKey = GlobalKey(); + bool _allowUnfocus = false; + + CryptoCurrency? _detectedCurrency; + String? _detectedAddress; + + String get _hintText => contactViewModel.record == null + ? 'Contacts allows you to create a profile with multiple addresses, as well as detect them automatically from social media profiles. Start by entering a social handle or an address manually' + : 'Add a new address or handle'; + + @override + void initState() { + super.initState(); + _controller.addListener(() => _handleChanged(_controller.text)); + _focusNode.addListener(() { + if (!_allowUnfocus && !_focusNode.hasFocus && mounted) { + Future.microtask(_requestFocus); + } + }); + WidgetsBinding.instance.addPostFrameCallback((_) => _requestFocus()); + } + + @override + void dispose() { + _debounce?.cancel(); + _controller.dispose(); + _focusNode.dispose(); + super.dispose(); + } + + void _handleChanged(String q) { + _typedText = q.trim(); + + setState(() { + _results = []; + _selectedHandle = null; + _plainSelected = null; + _detectedCurrency = null; + _detectedAddress = null; + _isSearching = false; + }); + + _debounce?.cancel(); + _searchIndicatorDebounce?.cancel(); + + if (_typedText.isEmpty) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _focusNode.requestFocus(); + }); + return; + } + + _searchIndicatorDebounce = Timer(_searchIndicatorDebouncer, () { + if (!mounted) return; + if (_controller.text.trim() == _typedText) { + setState(() => _isSearching = true); + _requestFocus(); + } + }); + + _debounce = Timer(_debouncer, () async { + if (!mounted) return; + + try { + final found = await resolver.resolve( + query: _typedText, + wallet: contactViewModel.wallet, + ); + + if (!mounted) return; + + if (found.isNotEmpty) { + setState(() { + _results = found; + _selectedHandle = found.length == 1 ? found.first : null; + _plainSelected = null; + _detectedCurrency = null; + _detectedAddress = null; + }); + } else { + final detected = AddressValidator.detectAddressBookCurrencies(_typedText); + setState(() { + if (detected.length == 1) { + _detectedCurrency = detected.first; + _detectedAddress = _typedText; + } else { + _detectedCurrency = null; + _detectedAddress = null; + _plainSelected = _PlainTextSelection(_typedText); + } + }); + } + _requestFocus(); + } finally { + if (mounted) setState(() => _isSearching = false); + } + }); + } + + void _requestFocus() { + if (ModalRoute.of(context)?.isCurrent != true) return; + + final ctx = _inputKey.currentContext ?? context; + if (!mounted) return; + FocusScope.of(ctx).requestFocus(_focusNode); + SystemChannels.textInput.invokeMethod('TextInput.show'); + } + + @override + Widget build(BuildContext context) { + final bottomInset = MediaQuery.of(context).viewInsets.bottom; + final hasDropdown = _results.isNotEmpty || + _detectedCurrency != null || + _plainSelected != null; + + return LayoutBuilder( + builder: (context, constraints) => SingleChildScrollView( + primary: false, + reverse: true, + padding: EdgeInsets.only(bottom: bottomInset), + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Image.asset('assets/images/add_contact_coins_img.png'), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Text(_hintText, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyLarge), + ), + SizedBox( + height: 160, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Column( + children: [ + const SizedBox(height: 16), + _buildInput(hasDropdown), + if (hasDropdown) _buildDropdown() else _handlesHint(), + ], + ), + ), + ), + Padding( + padding: const EdgeInsets.only(top: 8, bottom: 24), + child: LoadingPrimaryButton( + text: S.of(context).seed_language_next, + width: 150, + height: 40, + color: Theme.of(context).colorScheme.primary, + textColor: Theme.of(context).colorScheme.onPrimary, + isLoading: false, + isDisabled: _isSearching || + (_selectedHandle == null && + _detectedCurrency == null && + _plainSelected == null), + onPressed: _onNextPressed, + ), + ), + ], + ), + ), + ); + } + + Widget _buildInput(bool hasDropdown) { + final _noBorder = const OutlineInputBorder( + borderRadius: BorderRadius.all(Radius.circular(15)), + borderSide: BorderSide.none, + ); + + return SizedBox( + height: 50, + child: Stack( + clipBehavior: Clip.none, + children: [ + if (hasDropdown) + Positioned.fill( + child: IgnorePointer( + child: Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerLowest, + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(15), + topRight: Radius.circular(15), + ), + ), + ), + ), + ), + Positioned.fill( + child: StandardTextFormFieldWidget( + key: _inputKey, + controller: _controller, + focusNode: _focusNode, + labelText: 'Enter an address or handle', + fillColor: Theme.of(context).colorScheme.surfaceContainer, + outlineInputBorder: _noBorder, + enabledInputBorder: _noBorder, + focusedInputBorder: _noBorder, + onChanged: _handleChanged, + suffixIconConstraints: + const BoxConstraints(minWidth: 32, maxWidth: 40), + suffixIcon: _isSearching + ? const Padding( + padding: EdgeInsets.only(right: 8), + child: SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2)), + ) + : RoundedIconButton( + icon: Icons.paste_outlined, + iconSize: 20, + width: 38, + height: 36, + onPressed: () async { + final data = + await Clipboard.getData(Clipboard.kTextPlain); + final txt = data?.text?.trim() ?? ''; + if (txt.isEmpty) return; + _controller.text = txt; + _handleChanged(txt); + }, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(6)), + ), + ), + validator: (_) => null, + ), + ), + ], + ), + ); + } + + Widget _buildDropdown() { + final bgColor = Theme.of(context).colorScheme.surfaceContainerLowest; + + Widget _buildContent() { + if (_results.isNotEmpty) { + if (_results.length == 1) return _ParsedItem(item: _results.first); + + return _ParsedList( + items: _results, + selected: _selectedHandle, + onSelected: (sel) { + setState(() { + _selectedHandle = sel; + _plainSelected = null; + _detectedCurrency = null; + _detectedAddress = null; + }); + _focusNode.unfocus(); + }, + ); + } + + if (_detectedCurrency != null) + return _AddressRow(currency: _detectedCurrency!); + + if (_plainSelected != null) { + return ListTile( + dense: true, + visualDensity: const VisualDensity(horizontal: 0, vertical: -3), + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(12))), + title: Text('No address detected proceed with plain text', + style: Theme.of(context).textTheme.bodyLarge), + onTap: () => _focusNode.unfocus(), + ); + } + + return const SizedBox.shrink(); + } + + return ExcludeFocus( + excluding: true, + child: Container( + constraints: const BoxConstraints(maxHeight: 80), + decoration: BoxDecoration( + color: bgColor, + borderRadius: const BorderRadius.only( + bottomLeft: Radius.circular(12), + bottomRight: Radius.circular(12), + ), + ), + child: _buildContent(), + ), + ); + } + + Widget _handlesHint() => InkWell( + splashFactory: NoSplash.splashFactory, + onTap: () { + _focusNode.unfocus(); + Navigator.of(context).pushNamed(Routes.supportedHandlesPage); + }, + child: SizedBox( + height: 36, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), + child: Row( + children: [ + Text('View supported handles', + style: Theme.of(context).textTheme.bodyMedium), + const SizedBox(width: 4), + Icon(Icons.arrow_forward_ios, + size: 14, + color: Theme.of(context).colorScheme.onSurfaceVariant), + ], + ), + ), + ), + ); + + Future _onNextPressed() async { + _allowUnfocus = true; + + _debounce?.cancel(); + _searchIndicatorDebounce?.cancel(); + + FocusScope.of(context).unfocus(); + FocusManager.instance.primaryFocus?.unfocus(); + await SystemChannels.textInput.invokeMethod('TextInput.hide'); + + final record = contactViewModel.record; + + // Add parsed addresses to existing contact record + if (_selectedHandle != null && record != null) { + + // Add all parsed addresses directly to the existing contact record + final key = '${_selectedHandle!.addressSource.label}-${_selectedHandle!.handle}'.trim(); + + for (final e in _selectedHandle!.parsedAddressByCurrencyMap.entries) { + record.setParsedAddress(key, e.key, e.key.title, e.value.trim()); + } + + await Navigator.pushNamed(context, Routes.contactPage, arguments: record); + _allowUnfocus = false; + if (mounted) _requestFocus(); + + } + + + final args = _selectedHandle != null + ? [_selectedHandle!, record] + : _detectedCurrency != null && _detectedAddress != null + ? [ + ParsedAddress( + parsedAddressByCurrencyMap: {}, + manualAddressByCurrencyMap: { + _detectedCurrency!: _detectedAddress!.trim() + }, + addressSource: AddressSource.contact, + handle: '', + profileName: '', + profileImageUrl: 'assets/images/profile.png', + description: '', + ), + contactViewModel.record + ] + : _plainSelected != null + ? [ + ParsedAddress( + parsedAddressByCurrencyMap: {}, + manualAddressByCurrencyMap: {}, + addressSource: AddressSource.notParsed, + handle: '', + profileName: '', + profileImageUrl: '', + description: _plainSelected!.text, + ), + record + ] + : null; + + if (args == null) return; + + await Navigator.pushNamed(context, Routes.editNewContactPage, + arguments: args); + + _allowUnfocus = false; + if (mounted) _requestFocus(); + } +} + +class _ParsedItem extends StatelessWidget { + const _ParsedItem({required this.item}); + + final ParsedAddress item; + + @override + Widget build(BuildContext context) => ListTile( + dense: true, + visualDensity: const VisualDensity(horizontal: 0, vertical: -3), + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(12)), + ), + leading: ImageUtil.getImageFromPath( + imagePath: item.addressSource.iconPath, height: 24, width: 24), + title: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(item.addressSource.label, + style: Theme.of(context).textTheme.bodyLarge), + Row( + children: [ + if (item.profileImageUrl.isNotEmpty) + SizedBox( + width: 24, + height: 24, + child: ClipOval( + child: ImageUtil.getImageFromPath( + imagePath: item.profileImageUrl, + height: 24, + width: 24, + fit: BoxFit.cover, + ), + ), + ) + else + const SizedBox(width: 24, height: 24), + const SizedBox(width: 6), + Text(item.handle, + style: Theme.of(context).textTheme.bodyMedium), + ], + ), + ], + ), + ); +} + +class _ParsedList extends StatelessWidget { + const _ParsedList({ + required this.items, + required this.selected, + required this.onSelected, + }); + + final List items; + final ParsedAddress? selected; + final ValueChanged onSelected; + + @override + Widget build(BuildContext context) => ListView.separated( + shrinkWrap: true, + itemCount: items.length, + separatorBuilder: (_, __) => const SizedBox(height: 6), + itemBuilder: (_, i) { + final p = items[i]; + return ListTile( + dense: true, + visualDensity: const VisualDensity(horizontal: 0, vertical: -3), + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(12)), + ), + leading: ImageUtil.getImageFromPath( + imagePath: p.addressSource.iconPath, + height: 24, + width: 24, + ), + title: Text(p.addressSource.label, + style: Theme.of(context).textTheme.bodyLarge), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + ConstrainedBox( + constraints: + const BoxConstraints(maxWidth: 120, minWidth: 80), + child: Text(p.handle, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodyLarge), + ), + const SizedBox(width: 6), + Icon(selected == p ? Icons.check_circle : Icons.circle_outlined, + size: 20, color: Theme.of(context).colorScheme.primary), + ], + ), + onTap: () => onSelected(p), + ); + }, + ); +} + +class _AddressRow extends StatelessWidget { + const _AddressRow({required this.currency}); + + final CryptoCurrency currency; + + @override + Widget build(BuildContext context) => ListTile( + dense: true, + visualDensity: const VisualDensity(horizontal: 0, vertical: -3), + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(12))), + leading: ImageUtil.getImageFromPath( + imagePath: currency.iconPath ?? '', height: 24, width: 24), + title: Text(currency.fullName ?? currency.title, + style: Theme.of(context).textTheme.bodyLarge)); +} diff --git a/lib/src/screens/address_book/edit_address_page.dart b/lib/src/screens/address_book/edit_address_page.dart new file mode 100644 index 0000000000..2e614b51bf --- /dev/null +++ b/lib/src/screens/address_book/edit_address_page.dart @@ -0,0 +1,202 @@ +import 'package:cake_wallet/core/address_validator.dart'; +import 'package:cake_wallet/generated/i18n.dart'; +import 'package:cake_wallet/src/screens/address_book/sheet_page.dart'; +import 'package:cake_wallet/src/widgets/rounded_icon_button.dart'; +import 'package:cake_wallet/src/screens/address_book/widgets/standard_text_form_field_widget.dart'; +import 'package:cake_wallet/src/screens/exchange/widgets/currency_picker.dart'; +import 'package:cake_wallet/utils/image_utill.dart'; +import 'package:cake_wallet/utils/show_pop_up.dart'; +import 'package:cake_wallet/view_model/contact_list/contact_view_model.dart'; +import 'package:cw_core/crypto_currency.dart'; +import 'package:cw_core/currency.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_mobx/flutter_mobx.dart'; + +class EditAddressPage extends SheetPage { + EditAddressPage(this.list) + : contactViewModel = list[0] as ContactViewModel, + _formKey = GlobalKey(), + _oldLabel = list[2] as String, + _initialCur = list[1] as CryptoCurrency, + _labelController = TextEditingController(), + _addressController = TextEditingController() { + contactViewModel.currency = _initialCur; + _labelController.text = _oldLabel; + _addressController.text = contactViewModel.manual[contactViewModel.currency]?[_oldLabel] ?? ''; + } + + final List list; + final ContactViewModel contactViewModel; + final GlobalKey _formKey; + final String _oldLabel; + final CryptoCurrency _initialCur; + final TextEditingController _labelController; + final TextEditingController _addressController; + + @override + String get title => 'Edit Address'; + + @override + bool get resizeToAvoidBottomInset => true; + + @override + Widget body(BuildContext context) { + return Observer(builder: (_) { + final selectedCurrency = contactViewModel.currency!; + return SizedBox( + height: MediaQuery.of(context).size.height * 0.35, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Column( + children: [ + ListTile( + title: Text(selectedCurrency.fullName ?? selectedCurrency.name, + style: Theme.of(context).textTheme.bodyMedium), + trailing: Icon(Icons.keyboard_arrow_down_outlined, + size: 24, color: Theme.of(context).colorScheme.onSurface), + tileColor: Theme.of(context).colorScheme.surfaceContainer, + dense: true, + visualDensity: VisualDensity(horizontal: 0, vertical: -3), + contentPadding: EdgeInsets.symmetric(horizontal: 12), + leading: ImageUtil.getImageFromPath( + imagePath: selectedCurrency.iconPath ?? '', height: 24, width: 24), + shape: RoundedRectangleBorder( + borderRadius: const BorderRadius.all(Radius.circular(12)), + ), + onTap: () => _presentCurrencyPicker(context, contactViewModel)), + const SizedBox(height: 8), + StandardTextFormFieldWidget( + controller: _labelController, + labelText: 'Address label', + fillColor: Theme.of(context).colorScheme.surfaceContainer, + suffixIcon: RoundedIconButton( + icon: Icons.paste_outlined, + onPressed: () async { + final data = await Clipboard.getData(Clipboard.kTextPlain); + final text = data?.text ?? ''; + if (text.trim().isEmpty) return; + _labelController.text = text.trim(); + }, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(6)))), + validator: contactViewModel.manualAddressLabelValidator), + const SizedBox(height: 8), + StandardTextFormFieldWidget( + controller: _addressController, + labelText: S.of(context).address, + fillColor: Theme.of(context).colorScheme.surfaceContainer, + suffixIcon: RoundedIconButton( + icon: Icons.paste_outlined, + onPressed: () async { + final data = await Clipboard.getData(Clipboard.kTextPlain); + final text = data?.text ?? ''; + if (text.trim().isEmpty) return; + _addressController.text = text.trim(); + }, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(6)))), + validator: AddressValidator(type: contactViewModel.currency!)), + ], + ), + Padding( + padding: const EdgeInsets.only(bottom: 24), + child: Row( + children: [ + Padding( + padding: const EdgeInsets.only(right: 8), + child: RoundedIconButton( + iconWidget: Image.asset('assets/images/trash_can_icon.png', + color: Theme.of(context).colorScheme.onErrorContainer), + onPressed: () async { + await contactViewModel.deleteManualAddress( + currency: contactViewModel.currency!, label: _oldLabel); + if (context.mounted) Navigator.of(context).pop(); + }, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + width: 36, + height: 36, + iconSize: 24, + fillColor: Theme.of(context).colorScheme.errorContainer), + ), + Expanded( + child: ElevatedButton( + onPressed: () { + if (context.mounted) Navigator.of(context).pop(); + }, + style: ElevatedButton.styleFrom( + backgroundColor: Theme.of(context).colorScheme.surfaceContainer, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + ), + child: Text( + S.of(context).cancel, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onSecondaryContainer, + ), + ), + ), + ), + const SizedBox(width: 8), + Expanded( + child: ElevatedButton( + onPressed: () async { + if (!(_formKey.currentState?.validate() ?? false)) return; + + await contactViewModel.saveManualAddress( + oldCurrency: _initialCur, + selectedCurrency: contactViewModel.currency!, + oldLabel: _oldLabel, + newLabel: _labelController.text.trim(), + newAddress: _addressController.text.trim(), + ); + + if (context.mounted) Navigator.of(context).pop(); + }, + style: ElevatedButton.styleFrom( + backgroundColor: Theme.of(context).colorScheme.primary, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + ), + child: Text( + S.of(context).save, + maxLines: 1, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onPrimary, + ), + ), + ), + ), + ], + ), + ), + ], + ), + ), + ), + ); + }); + } +} + +void _presentCurrencyPicker(BuildContext context, ContactViewModel contactViewModel) { + showPopUp( + builder: (_) => CurrencyPicker( + selectedAtIndex: contactViewModel.currencies.indexOf(contactViewModel.currency!), + items: contactViewModel.currencies, + title: S.of(context).please_select, + hintText: S.of(context).search_currency, + onItemSelected: (Currency item) => contactViewModel.currency = item as CryptoCurrency), + context: context, + ); +} diff --git a/lib/src/screens/address_book/edit_alias_page.dart b/lib/src/screens/address_book/edit_alias_page.dart new file mode 100644 index 0000000000..164635c106 --- /dev/null +++ b/lib/src/screens/address_book/edit_alias_page.dart @@ -0,0 +1,137 @@ +import 'package:cake_wallet/generated/i18n.dart'; +import 'package:cake_wallet/src/screens/address_book/sheet_page.dart'; +import 'package:cake_wallet/src/widgets/rounded_icon_button.dart'; +import 'package:cake_wallet/view_model/contact_list/contact_view_model.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +class EditAliasPage extends SheetPage { + EditAliasPage({required this.contactViewModel, required this.handleKey}); + + final ContactViewModel contactViewModel; + final String handleKey; + + @override + String? get title => 'Edit Alias'; + + @override + Widget trailing(BuildContext context) { + return RoundedIconButton( + iconWidget: Image.asset('assets/images/trash_can_icon.png', + width: 16, height: 16, color: Theme.of(context).colorScheme.onErrorContainer), + fillColor: Theme.of(context).colorScheme.errorContainer, + onPressed: () async { + await contactViewModel.deleteParsedBlock(handleKey); + if (!context.mounted) return; + final navigator = Navigator.of(context); + (navigator.canPop() ? navigator : Navigator.of(context, rootNavigator: true)).pop(); + }); + } + + @override + Widget body(BuildContext context) { + final userName = handleKey.substring(handleKey.indexOf('-') + 1, handleKey.length); + return SizedBox( + height: MediaQuery.of(context).size.height * 0.35, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Column( + mainAxisSize: MainAxisSize.max, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Expanded( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 6), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainer, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Username', + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant)), + Text(userName, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.labelMedium), + ], + ), + ), + const SizedBox(width: 8), + RoundedIconButton( + icon: Icons.copy_all_outlined, + onPressed: () async => + await Clipboard.setData(ClipboardData(text: userName)), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(5), + ), + ), + const SizedBox(width: 8), + ], + ), + ), + ), + ], + ), + const Spacer(), + Padding( + padding: const EdgeInsets.only(bottom: 24), + child: Row( + children: [ + Expanded( + child: ElevatedButton( + onPressed: () { + if (context.mounted) Navigator.of(context).pop(); + }, + style: ElevatedButton.styleFrom( + backgroundColor: Theme.of(context).colorScheme.surfaceContainer, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + ), + child: Text( + S.of(context).cancel, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onSecondaryContainer, + ), + ), + ), + ), + const SizedBox(width: 8), + Expanded( + child: ElevatedButton( + onPressed: () { + if (context.mounted) Navigator.of(context).pop(); + }, + style: ElevatedButton.styleFrom( + backgroundColor: Theme.of(context).colorScheme.primary, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + ), + child: Text( + S.of(context).done, + maxLines: 1, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onPrimary, + ), + ), + ), + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/src/screens/address_book/edit_contact_page.dart b/lib/src/screens/address_book/edit_contact_page.dart new file mode 100644 index 0000000000..e6f5a45542 --- /dev/null +++ b/lib/src/screens/address_book/edit_contact_page.dart @@ -0,0 +1,163 @@ +import 'package:cake_wallet/generated/i18n.dart'; +import 'package:cake_wallet/src/screens/address_book/sheet_page.dart'; +import 'package:cake_wallet/src/widgets/rounded_icon_button.dart'; +import 'package:cake_wallet/src/screens/address_book/widgets/standard_text_form_field_widget.dart'; +import 'package:cake_wallet/view_model/contact_list/contact_view_model.dart'; +import 'package:flutter/material.dart'; + +class EditContactPage extends SheetPage { + EditContactPage({ + required this.contactViewModel, + }) : _formKey = GlobalKey(), + _contactNameController = TextEditingController(text: contactViewModel.name) {} + + final ContactViewModel contactViewModel; + + final GlobalKey _formKey; + final TextEditingController _contactNameController; + + @override + String? get title => 'Edit Contact'; + + @override + bool get resizeToAvoidBottomInset => true; + + @override + Widget trailing(BuildContext context) { + return RoundedIconButton( + iconWidget: Image.asset('assets/images/trash_can_icon.png', + width: 16, height: 16, color: Theme.of(context).colorScheme.onErrorContainer), + fillColor: Theme.of(context).colorScheme.errorContainer, + onPressed: () async { + await contactViewModel.deleteContact(); + if (context.mounted) Navigator.of(context, rootNavigator: true).pop(); + }, + ); + } + + @override + Widget body(BuildContext context) { + final theme = Theme.of(context); + return SizedBox( + height: MediaQuery.of(context).size.height * 0.35, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + GestureDetector( + onTap: () { + //edit avatar + }, + child: ConstrainedBox( + constraints: const BoxConstraints( + minWidth: 44, + maxWidth: 44, + minHeight: 44, + maxHeight: 44, + ), + child: Container( + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainer, + borderRadius: const BorderRadius.all(Radius.circular(12)), + ), + child: Padding( + padding: const EdgeInsets.fromLTRB(8, 4, 8, 1), + child: Column( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(5), + child: Image( + width: 24, + height: 24, + image: contactViewModel.avatar, + fit: BoxFit.cover, + ), + ), + const SizedBox(height: 1), + Text('Icon', + style: theme.textTheme.labelSmall?.copyWith( + fontSize: 8, + color: theme.colorScheme.onSurfaceVariant, + )), + ], + ), + ), + ), + ), + ), + const SizedBox(width: 6), + Expanded( + child: Form( + key: _formKey, + child: StandardTextFormFieldWidget( + controller: _contactNameController, + labelText: 'Address group name', + fillColor: theme.colorScheme.surfaceContainer, + validator: contactViewModel.contactNameValidator), + ), + ), + ], + ), + Padding( + padding: const EdgeInsets.only(bottom: 24), + child: Row( + children: [ + Expanded( + child: ElevatedButton( + onPressed: () { + if (context.mounted) Navigator.of(context).pop(); + }, + style: ElevatedButton.styleFrom( + backgroundColor: Theme.of(context).colorScheme.surfaceContainer, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + ), + child: Text( + S.of(context).cancel, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onSecondaryContainer, + ), + ), + ), + ), + const SizedBox(width: 8), + Expanded( + child: ElevatedButton( + onPressed: () async { + if (!(_formKey.currentState?.validate() ?? false)) return; + + contactViewModel.name = _contactNameController.text; + await contactViewModel.saveContactInfo(); + + if (context.mounted) Navigator.of(context).pop(); + }, + style: ElevatedButton.styleFrom( + backgroundColor: Theme.of(context).colorScheme.primary, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + ), + child: Text( + S.of(context).save, + maxLines: 1, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onPrimary, + ), + ), + ), + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/src/screens/address_book/edit_new_contact_page.dart b/lib/src/screens/address_book/edit_new_contact_page.dart new file mode 100644 index 0000000000..1aa8a952f5 --- /dev/null +++ b/lib/src/screens/address_book/edit_new_contact_page.dart @@ -0,0 +1,387 @@ +import 'package:cake_wallet/core/address_validator.dart'; +import 'package:cake_wallet/entities/contact.dart'; +import 'package:cake_wallet/entities/contact_record.dart'; +import 'package:cake_wallet/address_resolver/parsed_address.dart'; +import 'package:cake_wallet/generated/i18n.dart'; +import 'package:cake_wallet/routes.dart'; +import 'package:cake_wallet/src/screens/address_book/sheet_page.dart'; +import 'package:cake_wallet/src/widgets/rounded_icon_button.dart'; +import 'package:cake_wallet/src/screens/address_book/widgets/standard_text_form_field_widget.dart'; +import 'package:cake_wallet/src/screens/exchange/widgets/currency_picker.dart'; +import 'package:cake_wallet/src/widgets/primary_button.dart'; +import 'package:cake_wallet/utils/image_utill.dart'; +import 'package:cake_wallet/utils/show_pop_up.dart'; +import 'package:cake_wallet/view_model/contact_list/contact_view_model.dart'; +import 'package:cw_core/crypto_currency.dart'; +import 'package:cw_core/currency.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_mobx/flutter_mobx.dart'; + +class EditNewContactPage extends SheetPage { + EditNewContactPage({ + required this.selectedParsedAddress, + required this.contactViewModel, + }) : _formKey = GlobalKey(), + _contactNameController = TextEditingController(), + _labelController = TextEditingController(), + _addressController = TextEditingController() { + _contactNameController.text = _isExisting + ? contactViewModel.record!.profileName + : selectedParsedAddress.profileName.isEmpty + ? selectedParsedAddress.handle + : selectedParsedAddress.profileName; + + contactViewModel.currency = + _isManualFlow ? selectedParsedAddress.manualAddressByCurrencyMap?.keys.firstOrNull : null; + + if (_isManualFlow && contactViewModel.currency != null) { + _addressController.text = + selectedParsedAddress.manualAddressByCurrencyMap?[contactViewModel.currency!] ?? ''; + } else if (_isPlainFlow) { + _addressController.text = selectedParsedAddress.description; + } + + _currencyPicked = contactViewModel.currency != null; + } + + final ParsedAddress selectedParsedAddress; + final ContactViewModel contactViewModel; + + final GlobalKey _formKey; + final TextEditingController _contactNameController; + final TextEditingController _labelController; + final TextEditingController _addressController; + + bool _currencyPicked = false; + + bool get _isExisting => contactViewModel.record != null; + + bool get _isHandleFlow => + selectedParsedAddress.addressSource != AddressSource.contact && + selectedParsedAddress.addressSource != AddressSource.notParsed; + + bool get _isPlainFlow => selectedParsedAddress.addressSource == AddressSource.notParsed; + + bool get _isManualFlow => selectedParsedAddress.addressSource == AddressSource.contact; + + @override + String? get title => _isExisting + ? _isHandleFlow + ? 'New contact info from handle' + : 'New manual address' + : 'New contact'; + + @override + bool get resizeToAvoidBottomInset => true; + + @override + Widget body(BuildContext context) { + final theme = Theme.of(context); + final showAddrFields = !_isHandleFlow; + return SizedBox( + height: showAddrFields + ? MediaQuery.of(context).size.height * .45 + : MediaQuery.of(context).size.height * .35, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Form( + key: _formKey, + child: Column( + children: [ + Text( + _isExisting + ? _isHandleFlow + ? 'auto-detected from ${selectedParsedAddress.addressSource.label}' + : 'Review & save manual address' + : 'Choose a contact name and icon', + style: theme.textTheme.bodyLarge, + ), + const SizedBox(height: 12), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _iconBox(theme), + const SizedBox(width: 8), + Expanded( + child: TextFormField( + controller: _contactNameController, + readOnly: _isExisting, + decoration: InputDecoration( + isDense: true, + isCollapsed: true, + contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), + labelText: 'Address group name', + fillColor: theme.colorScheme.surfaceContainer, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(15), borderSide: BorderSide.none), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(15), borderSide: BorderSide.none), + ), + validator: contactViewModel.contactNameValidator), + ), + ], + ), + const SizedBox(height: 16), + if (showAddrFields) + Column( + children: [ + Observer( + builder: (_) { + String? Function(CryptoCurrency?) currencyValidator = + (c) => c == null ? 'Please pick a currency' : null; + + return FormField( + initialValue: contactViewModel.currency, + validator: currencyValidator, + builder: (field) { + final currency = field.value; + final hasError = field.hasError; + final scheme = Theme.of(context).colorScheme; + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Container( + decoration: BoxDecoration( + color: scheme.surfaceContainer, + borderRadius: BorderRadius.circular(12), + border: Border.all( + width: 1.3, + color: hasError ? scheme.error : Colors.transparent, + ), + ), + child: ListTile( + dense: true, + visualDensity: const VisualDensity(horizontal: 0, vertical: -3), + contentPadding: const EdgeInsets.symmetric(horizontal: 12), + leading: currency == null + ? null + : ImageUtil.getImageFromPath( + imagePath: currency.iconPath ?? '', + height: 24, + width: 24, + ), + title: Text( + currency?.fullName ?? currency?.name ?? 'Choose currency', + style: Theme.of(context).textTheme.bodyMedium, + ), + trailing: Icon(Icons.keyboard_arrow_down_outlined, + color: scheme.onSurface), + onTap: () async { + final picked = await _presentCurrencyPicker(context); + if (picked != null) { + contactViewModel.currency = picked; + field.didChange(picked); // refresh validator below + _currencyPicked = true; + } + }, + ), + ), + if (hasError) + Padding( + padding: const EdgeInsets.only(top: 4, left: 12), + child: Text(field.errorText!, + style: Theme.of(context) + .textTheme + .bodySmall + ?.copyWith(color: scheme.error)), + ), + ], + ); + }, + ); + }, + ), + const SizedBox(height: 8), + StandardTextFormFieldWidget( + controller: _labelController, + labelText: 'Address label', + fillColor: theme.colorScheme.surfaceContainer, + validator: contactViewModel.manualAddressLabelValidator, + suffixIcon: _pasteButton(() async { + _labelController.text = await _clipboardText; + }), + ), + const SizedBox(height: 8), + Observer( + builder: (_) { + final cur = contactViewModel.currency; + final String? Function(String?)? addrValidator = + cur == null ? null : AddressValidator(type: cur).call; + + return StandardTextFormFieldWidget( + controller: _addressController, + labelText: S.of(context).address, + fillColor: theme.colorScheme.surfaceContainer, + validator: addrValidator, + suffixIcon: _pasteButton(() async { + _addressController.text = await _clipboardText; + }), + ); + }, + ), + ], + ), + const Spacer(), + _nextButton(context), + const SizedBox(height: 24), + ], + ), + ), + ), + ); + } + + Widget _iconBox(ThemeData theme) => ConstrainedBox( + constraints: const BoxConstraints(minWidth: 44, maxWidth: 44, minHeight: 44, maxHeight: 44), + child: Container( + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainer, + borderRadius: BorderRadius.circular(12), + ), + child: Padding( + padding: const EdgeInsets.fromLTRB(8, 4, 8, 1), + child: Column( + children: [ + ImageUtil.getImageFromPath( + imagePath: selectedParsedAddress.profileImageUrl, + height: 24, + width: 24, + borderRadius: 30, + ), + const SizedBox(height: 1), + Text('Icon', + style: + theme.textTheme.labelSmall?.copyWith(fontSize: 8, color: theme.hintColor)) + ], + ), + ), + ), + ); + + Widget _pasteButton(Future Function() setText) => RoundedIconButton( + icon: Icons.paste_outlined, + onPressed: setText, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(6)), + ); + + Widget _nextButton(BuildContext context) => LoadingPrimaryButton( + text: 'Next', + width: 150, + height: 40, + onPressed: () async { + if (!(_formKey.currentState?.validate() ?? false)) return; + if (!_isHandleFlow && !_currencyPicked) return; + + if (_isExisting) { + final record = contactViewModel.record!; + + if (_isManualFlow || _isPlainFlow) { + final cur = contactViewModel.currency; + + if (cur == null) { + return; + } + + final label = _labelController.text.trim(); + final newAddr = _addressController.text.trim(); + final exists = record.manual[cur]?.containsKey(label) ?? false; + + if (exists) { + await contactViewModel.saveManualAddress( + oldCurrency: cur, + selectedCurrency: cur, + oldLabel: label, + newLabel: label, + newAddress: newAddr, + ); + } else { + record.setManualAddress(cur, label, newAddr); + } + } else { + final key = '${selectedParsedAddress.addressSource.label}' + '-${selectedParsedAddress.handle}' + .trim(); + for (final e in selectedParsedAddress.parsedAddressByCurrencyMap.entries) { + record.setParsedAddress(key, e.key, e.key.title, e.value.trim()); + } + } + + record.original + ..lastChange = DateTime.now() + ..save(); + + if (!context.mounted) return; + Navigator.of(context).pop(); + Navigator.pushReplacementNamed( + context, + Routes.contactPage, + arguments: record, + ); + return; + } + + final localImg = await ImageUtil.saveAvatarLocally(selectedParsedAddress.profileImageUrl); + + ParsedAddress payload; + if (_isHandleFlow) { + payload = selectedParsedAddress.copyWith( + profileName: _contactNameController.text.trim(), + ); + } else { + final selectedCurrency = contactViewModel.currency; + if (selectedCurrency == null) return; + payload = ParsedAddress( + parsedAddressByCurrencyMap: const {}, + manualAddressByCurrencyMap: { + selectedCurrency: _addressController.text.trim(), + }, + addressSource: AddressSource.contact, + handle: '', + profileName: _contactNameController.text.trim(), + profileImageUrl: selectedParsedAddress.profileImageUrl, + description: '', + ); + } + + final newContact = Contact.fromParsed(payload, localImage: localImg); + contactViewModel.box.add(newContact); + final record = ContactRecord(contactViewModel.box, newContact); + if (!context.mounted) return; + Navigator.pushReplacementNamed( + context, + Routes.contactPage, + arguments: record, + ); + }, + color: Theme.of(context).colorScheme.primary, + textColor: Theme.of(context).colorScheme.onPrimary, + isLoading: false, + isDisabled: false, + ); + + Future _presentCurrencyPicker(BuildContext context) async { + CryptoCurrency? currency; + await showPopUp( + context: context, + builder: (_) => CurrencyPicker( + selectedAtIndex: _currencyPicked && contactViewModel.currency != null + ? contactViewModel.currencies.indexOf(contactViewModel.currency!) + : 0, + items: contactViewModel.currencies, + title: S.of(context).please_select, + hintText: S.of(context).search_currency, + onItemSelected: (Currency item) { + currency = item as CryptoCurrency; + }, + ), + ); + return currency; + } + + Future get _clipboardText async { + final data = await Clipboard.getData(Clipboard.kTextPlain); + return data?.text?.trim() ?? ''; + } +} diff --git a/lib/src/screens/address_book/entities/address_edit_request.dart b/lib/src/screens/address_book/entities/address_edit_request.dart new file mode 100644 index 0000000000..10eaa0b0dd --- /dev/null +++ b/lib/src/screens/address_book/entities/address_edit_request.dart @@ -0,0 +1,52 @@ +import 'package:cake_wallet/entities/contact_record.dart'; +import 'package:cw_core/crypto_currency.dart'; + +enum EditMode { + contactFields, + manualAddressAdd, + manualAddressEdit, + parsedAddressAdd, + parsedAddressEdit, +} + +class AddressEditRequest { + factory AddressEditRequest.contact(ContactRecord? c) => AddressEditRequest._( + contact: c, + mode: EditMode.contactFields, + ); + + factory AddressEditRequest.address({ + required ContactRecord? contact, + required CryptoCurrency currency, + String? label, + required bool kindIsManual, + final String? handle, + String? handleKey, + }) => + AddressEditRequest._( + contact: contact, + currency: currency, + label: label, + kindIsManual: kindIsManual, + handleKey: handleKey, + mode: label == null + ? (kindIsManual ? EditMode.manualAddressAdd : EditMode.parsedAddressAdd) + : (kindIsManual ? EditMode.manualAddressEdit : EditMode.parsedAddressEdit), + ); + + const AddressEditRequest._({ + this.contact, + this.currency, + this.label, + this.kindIsManual = false, + this.handleKey, + required this.mode, + }); + + final ContactRecord? contact; + final CryptoCurrency? currency; + final String? label; + final bool kindIsManual; + final EditMode mode; + final String? handleKey; +} diff --git a/lib/src/screens/address_book/sheet_page.dart b/lib/src/screens/address_book/sheet_page.dart new file mode 100644 index 0000000000..b876592896 --- /dev/null +++ b/lib/src/screens/address_book/sheet_page.dart @@ -0,0 +1,99 @@ +import 'package:cake_wallet/di.dart'; +import 'package:cake_wallet/themes/core/material_base_theme.dart'; +import 'package:cake_wallet/themes/core/theme_store.dart'; +import 'package:flutter/material.dart'; + +abstract class SheetPage extends StatelessWidget { + SheetPage({super.key}); + + MaterialThemeBase get currentTheme => getIt().currentTheme; + + Color pageBackgroundColor(BuildContext context) => Theme.of(context).colorScheme.surface; + + Color iconColor(BuildContext context) => Theme.of(context).colorScheme.onSurface; + + bool get resizeToAvoidBottomInset => false; + + Widget _backButton(BuildContext context) => + Icon(Icons.arrow_back_ios, size: 14, color: iconColor(context)); + + void _onClose(BuildContext context) => Navigator.of(context).maybePop(); + + String? get title => null; + + Widget? leading(BuildContext context) { + if (ModalRoute.of(context)?.isFirst ?? true) return null; + + return SizedBox( + width: 24, + height: 24, + child: IconButton( + icon: _backButton(context), + onPressed: () => _onClose(context), + padding: EdgeInsets.zero, + constraints: const BoxConstraints.tightFor( + width: 24, + height: 24, + ), + splashRadius: 14, + visualDensity: VisualDensity.compact, + ), + ); + } + + Widget? middle(BuildContext context) => title == null + ? null + : Text( + title!, + style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600), + ); + + Widget? trailing(BuildContext context) => null; + + Widget body(BuildContext context); + + @override + Widget build(BuildContext context) { + Widget content = Material( + color: pageBackgroundColor(context), + child: SafeArea( + top: false, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 12), + child: Stack( + alignment: Alignment.center, + children: [ + if (middle(context) != null) middle(context)!, + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + leading(context) ?? const SizedBox(width: 48), + trailing(context) ?? const SizedBox(width: 48), + ], + ), + ], + ), + ), + Flexible(child: body(context)), + ], + ), + ), + ); + + if (resizeToAvoidBottomInset) { + content = AnimatedPadding( + padding: MediaQuery.of(context).viewInsets, + duration: const Duration(milliseconds: 250), + curve: Curves.easeOut, + child: content, + ); + } + + + return content; + + } +} diff --git a/lib/src/screens/address_book/supported_handles_page.dart b/lib/src/screens/address_book/supported_handles_page.dart new file mode 100644 index 0000000000..4d46d00436 --- /dev/null +++ b/lib/src/screens/address_book/supported_handles_page.dart @@ -0,0 +1,35 @@ +import 'package:cake_wallet/address_resolver/parsed_address.dart'; +import 'package:cake_wallet/src/screens/address_book/sheet_page.dart'; +import 'package:cake_wallet/src/screens/address_book/widgets/handles_list_widget.dart'; +import 'package:cake_wallet/view_model/contact_list/contact_view_model.dart'; +import 'package:flutter/material.dart'; + +class SupportedHandlesPage extends SheetPage { + SupportedHandlesPage({required this.contactViewModel}); + + final ContactViewModel contactViewModel; + + @override + String? get title => 'Supported Handles'; + + @override + Widget body(BuildContext context) { + final selectedInit = [ + for (final src in supportedSources) + if (contactViewModel.lookupMap[src.label]!.$1()) src + ]; + + return HandlesListWidget( + items: supportedSources, + initiallySelected: selectedInit, + onSelectionChanged: (sel) { + for (final src in supportedSources) { + final pair = contactViewModel.lookupMap[src.label]!; + pair.$2(sel.contains(src)); + } + }, + ); + } +} + + diff --git a/lib/src/screens/address_book/widgets/addresses_expansion_tile_widget.dart b/lib/src/screens/address_book/widgets/addresses_expansion_tile_widget.dart new file mode 100644 index 0000000000..51c2d1c945 --- /dev/null +++ b/lib/src/screens/address_book/widgets/addresses_expansion_tile_widget.dart @@ -0,0 +1,137 @@ +import 'package:cake_wallet/src/widgets/rounded_icon_button.dart'; +import 'package:cake_wallet/utils/address_formatter.dart'; +import 'package:cake_wallet/utils/image_utill.dart'; +import 'package:cw_core/crypto_currency.dart'; +import 'package:cw_core/wallet_type.dart'; +import 'package:flutter/material.dart'; + +typedef EditCallback = void Function(CryptoCurrency currency, String label); +typedef OnStringAction = void Function(String address); +typedef AddressByCurrencyMap = Map>; + +class ContactAddressesExpansionTile extends StatelessWidget { + const ContactAddressesExpansionTile({ + super.key, + required this.addressByCurrency, + required this.fillColor, + this.title, + this.contentPadding, + this.tilePadding, + this.onEditPressed, + this.onCopyPressed, + this.onAddressPressed, + this.initiallyExpanded = false, + this.shouldTruncateContent = true, + }); + + final AddressByCurrencyMap addressByCurrency; + final Color fillColor; + final Widget? title; + final EdgeInsetsGeometry? contentPadding; + final EdgeInsetsGeometry? tilePadding; + final EditCallback? onEditPressed; + final OnStringAction? onCopyPressed; + final OnStringAction? onAddressPressed; + final bool initiallyExpanded; + final bool shouldTruncateContent; + + Widget _addressRow(BuildContext context, + {required CryptoCurrency currency, required String label, required String address}) { + return ListTile( + title: Text(label, style: Theme + .of(context) + .textTheme + .bodyMedium), + subtitle: AddressFormatter.buildSegmentedAddress( + address: address, + walletType: cryptoCurrencyToWalletType(currency), + evenTextStyle: Theme + .of(context) + .textTheme + .labelSmall!, + visibleChunks: 4, + shouldTruncate: shouldTruncateContent), + leading: + ImageUtil.getImageFromPath(imagePath: currency.iconPath ?? '', height: 24, width: 24), + trailing: onEditPressed != null || onCopyPressed != null ? Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (onEditPressed != null) + RoundedIconButton( + icon: Icons.edit, + onPressed: () => onEditPressed?.call(currency, label) + ), + const SizedBox(width: 8), + if (onCopyPressed != null) + RoundedIconButton( + icon: Icons.copy_all_outlined, + onPressed: () => onCopyPressed?.call(address), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(5), + ), + ), + ], + ) : null, + dense: true, + visualDensity: const VisualDensity(horizontal: 0, vertical: -4), + contentPadding: contentPadding ?? + const EdgeInsets.only(left: 24, right: 16), + onTap: () => onAddressPressed?.call(address), + ); + } + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10), + color: fillColor, + ), + child: Theme( + data: Theme.of(context).copyWith( + splashColor: Colors.transparent, + highlightColor: Colors.transparent, + hoverColor: Colors.transparent, + dividerColor: Colors.transparent, + listTileTheme: const ListTileThemeData( + dense: true, + minLeadingWidth: 0, + horizontalTitleGap: 8, + minVerticalPadding: 0, + contentPadding: EdgeInsets.zero, + visualDensity: VisualDensity( + horizontal: -4, + vertical: -4, + ), + ), + expansionTileTheme: const ExpansionTileThemeData( + tilePadding: EdgeInsets.zero, + childrenPadding: EdgeInsets.zero, + ), + ), + child: ExpansionTile( + iconColor: Theme + .of(context) + .colorScheme + .onSurfaceVariant, + tilePadding: tilePadding ?? const EdgeInsets.only(left: 8, right: 16), + initiallyExpanded: initiallyExpanded, + dense: true, + visualDensity: VisualDensity.compact, + title: title ?? const SizedBox(), + children: [ + for (final curEntry in addressByCurrency.entries) ...[ + for (final labelEntry in curEntry.value.entries) + _addressRow( + context, + currency: curEntry.key, + label: labelEntry.key, + address: labelEntry.value, + ), + ], + ], + ), + ), + ); + } +} diff --git a/lib/src/screens/address_book/widgets/contact_list_tab_widget.dart b/lib/src/screens/address_book/widgets/contact_list_tab_widget.dart new file mode 100644 index 0000000000..a8720e2c2d --- /dev/null +++ b/lib/src/screens/address_book/widgets/contact_list_tab_widget.dart @@ -0,0 +1,174 @@ +import 'package:cake_wallet/entities/contact_record.dart'; +import 'package:cake_wallet/address_resolver/parsed_address.dart'; +import 'package:cake_wallet/entities/wallet_list_order_types.dart'; +import 'package:cake_wallet/routes.dart'; +import 'package:cake_wallet/src/screens/dashboard/widgets/filter_list_widget.dart'; +import 'package:cake_wallet/src/screens/wallet_list/filtered_list.dart'; +import 'package:cake_wallet/src/widgets/bottom_sheet/contact_bottom_sheet_widget.dart'; +import 'package:cake_wallet/src/widgets/search_bar_widget.dart'; +import 'package:cake_wallet/utils/image_utill.dart'; +import 'package:cake_wallet/utils/show_pop_up.dart'; +import 'package:cake_wallet/view_model/contact_list/contact_list_view_model.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_mobx/flutter_mobx.dart'; +import 'package:mobx/mobx.dart'; + +class ContactListBody extends StatefulWidget { + const ContactListBody({ + required this.contactListViewModel, + required this.tabController, + super.key, + }); + + final ContactListViewModel contactListViewModel; + final TabController tabController; + + @override + State createState() => _ContactListBodyState(); +} + +class _ContactListBodyState extends State { + final _searchCtrl = TextEditingController(); + + late final VoidCallback _tabListener; + late final VoidCallback _searchListener; + + bool get _contactsTab => widget.tabController.index == 1; + + ContactListViewModel get _viewModel => widget.contactListViewModel; + + @override + void initState() { + super.initState(); + + _tabListener = () { + if (!mounted) return; + setState(() {}); + }; + widget.tabController.addListener(_tabListener); + + _searchListener = () { + if (!mounted) return; + setState(() {}); + }; + _searchCtrl.addListener(_searchListener); + } + + @override + void dispose() { + widget.tabController.removeListener(_tabListener); + _searchCtrl.removeListener(_searchListener); + _searchCtrl.dispose(); + + if (_viewModel.settingsStore.contactListOrder == + FilterListOrderType.Custom) { + _viewModel.saveCustomOrder(); + } + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final query = _searchCtrl.text.trim().toLowerCase(); + final editable = _viewModel.isEditable && query.isEmpty; + + + return Scaffold( + resizeToAvoidBottomInset: true, + backgroundColor: Theme.of(context).colorScheme.surface, + floatingActionButton: _contactsTab && editable ? _filterBtn(context) : null, + body: Padding( + padding: const EdgeInsets.fromLTRB(18, 8, 18, 0), + child: Column( + children: [ + SearchBarWidget(key: const ValueKey('contact_search'), searchController: _searchCtrl), + const SizedBox(height: 8), + Expanded( + child: Observer( + builder: (_) { + final list = ObservableList.of( + _viewModel.contactsToShow + .where((c) => c.name.toLowerCase().contains(query)), + ); + + return FilteredList( + list: list, + canReorder: editable, + updateFunction: () => _viewModel.saveCustomOrderFrom(list), + itemBuilder: (context, i) => _item(context, list[i]), + ); + }, + ), + ), + ], + ), + ), + ); + } + + Widget _item(BuildContext ctx, ContactRecord c) { + final cur = _viewModel.selectedCurrency; + final bg = Theme.of(ctx).colorScheme.surfaceContainer; + + return Material( + color: bg, + borderRadius: BorderRadius.circular(10), + child: ListTile( + key: ValueKey(c.key), + dense: true, + visualDensity: const VisualDensity(horizontal: 0, vertical: -3), + contentPadding: const EdgeInsets.symmetric(horizontal: 8), + leading: ClipRRect( + borderRadius: BorderRadius.circular(5), + child: Image(image: c.avatarProvider, width: 24, height: 24, fit: BoxFit.cover), + ), + title: Text(c.name, style: Theme.of(ctx).textTheme.bodyMedium), + onTap: () => _openSheet( + ctx, + cur != null ? Routes.contactRefreshPage : Routes.contactPage, + cur != null ? [c, cur] : c, + ), + ), + ); + } + + Widget _filterBtn(BuildContext ctx) => SizedBox( + height: 58, + width: 58, + child: GestureDetector( + onTap: () async { + await showPopUp( + context: ctx, + builder: (_) => FilterListWidget( + initalType: _viewModel.orderType, + initalAscending: _viewModel.ascending, + onClose: (asc, type) async { + _viewModel.setAscending(asc); + await _viewModel.setOrderType(type); + }, + showGroupByType: false, + ), + ); + }, + child: Container( + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Theme.of(ctx).colorScheme.surfaceContainer, + ), + child: Image.asset( + 'assets/images/filter_icon.png', + color: Theme.of(ctx).colorScheme.onSurface, + ), + ), + ), + ); + + Future _openSheet(BuildContext ctx, String route, Object args) async { + final res = await showModalBottomSheet<(ContactRecord, String)>( + context: ctx, + isScrollControlled: true, + builder: (_) => AddressBookBottomSheet(initialRoute: route, initialArgs: args), + ); + if (res?.$1 != null && ctx.mounted) Navigator.of(ctx).pop(res); + } +} diff --git a/lib/src/screens/address_book/widgets/handles_list_widget.dart b/lib/src/screens/address_book/widgets/handles_list_widget.dart new file mode 100644 index 0000000000..512cf65f9f --- /dev/null +++ b/lib/src/screens/address_book/widgets/handles_list_widget.dart @@ -0,0 +1,83 @@ +import 'package:flutter/material.dart'; +import 'package:cake_wallet/address_resolver/parsed_address.dart'; +import 'package:cake_wallet/utils/image_utill.dart'; + +class HandlesListWidget extends StatefulWidget { + const HandlesListWidget( + {super.key, + required this.items, + this.initiallySelected = const [], + this.onSelectionChanged}); + + final List items; + + final List initiallySelected; + + final ValueChanged>? onSelectionChanged; + + @override + State createState() => _HandlesListWidgetState(); +} + +class _HandlesListWidgetState extends State { + late final Set _selected = widget.initiallySelected.toSet(); + + void _toggle(AddressSource src) { + setState(() { + if (_selected.contains(src)) { + _selected.remove(src); + } else { + _selected.add(src); + } + }); + widget.onSelectionChanged?.call(_selected); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final iconColor = theme.colorScheme.primary; + + return ListView.separated( + shrinkWrap: true, + primary: false, + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), + itemCount: widget.items.length, + separatorBuilder: (_, __) => const SizedBox(height: 6), + itemBuilder: (ctx, i) { + final src = widget.items[i]; + final isSelected = _selected.contains(src); + + return ListTile( + title: Text(src.label, style: theme.textTheme.bodyMedium), + trailing: Text(src.alias, style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant),), + tileColor: theme.colorScheme.surfaceContainer, + splashColor: Colors.transparent, + dense: true, + visualDensity: const VisualDensity(horizontal: 0, vertical: -3), + contentPadding: const EdgeInsets.symmetric(horizontal: 12), + leading: Row( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: 24, + height: 24, + child: isSelected + ? Icon(Icons.check_circle, size: 20, color: iconColor) + : Icon(Icons.circle_outlined, size: 20, color: iconColor), + ), + const SizedBox(width: 6), + ImageUtil.getImageFromPath( + imagePath: src.iconPath, + height: 24, + width: 24, + ), + ], + ), + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(12))), + onTap: () => _toggle(src), + ); + }, + ); + } +} diff --git a/lib/src/screens/address_book/widgets/standard_text_form_field_widget.dart b/lib/src/screens/address_book/widgets/standard_text_form_field_widget.dart new file mode 100644 index 0000000000..b73b4f68cf --- /dev/null +++ b/lib/src/screens/address_book/widgets/standard_text_form_field_widget.dart @@ -0,0 +1,83 @@ +import 'package:flutter/material.dart'; + +class StandardTextFormFieldWidget extends StatelessWidget { + const StandardTextFormFieldWidget({ + super.key, + required this.controller, + required this.labelText, + required this.fillColor, + required this.validator, + this.focusNode, + this.suffixIcon, + this.prefixIcon, + this.suffix, + this.onChanged, + this.suffixIconConstraints, + this.prefixIconConstraints, + this.outlineInputBorder, + this.enabledInputBorder, + this.focusedInputBorder, + this.readOnly = false, + }); + + final TextEditingController controller; + final String labelText; + final Color fillColor; + final String? Function(String?)? validator; + final FocusNode? focusNode; + final Widget? suffixIcon; + final Widget? prefixIcon; + final Widget? suffix; + final BoxConstraints? suffixIconConstraints; + final BoxConstraints? prefixIconConstraints; + final void Function(String)? onChanged; + final InputBorder? outlineInputBorder; + final InputBorder? enabledInputBorder; + final InputBorder? focusedInputBorder; + final bool readOnly; + + @override + Widget build(BuildContext context) { + return TextFormField( + controller: controller, + focusNode: focusNode, + readOnly: readOnly, + decoration: InputDecoration( + isDense: true, + isCollapsed: true, + contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), + labelText: labelText, + labelStyle: + Theme.of(context).textTheme.bodyMedium!.copyWith(color: Theme.of(context).hintColor), + hintStyle: + Theme.of(context).textTheme.bodyMedium!.copyWith(color: Theme.of(context).hintColor), + fillColor: fillColor, + border: outlineInputBorder ?? + OutlineInputBorder( + borderRadius: BorderRadius.all(Radius.circular(15)), + borderSide: BorderSide(color: Theme.of(context).colorScheme.outline)), + enabledBorder: enabledInputBorder ?? + OutlineInputBorder( + borderRadius: BorderRadius.all(Radius.circular(15)), + borderSide: BorderSide(color: Colors.transparent)), + focusedBorder: focusedInputBorder ?? + OutlineInputBorder( + borderRadius: BorderRadius.all(Radius.circular(15)), + borderSide: BorderSide(color: Theme.of(context).colorScheme.primary)), + suffixIcon: Padding(padding: const EdgeInsets.only(right: 10), child: suffixIcon), + suffix: suffix, + prefixIcon: prefixIcon, + prefixIconConstraints: prefixIconConstraints, + suffixIconConstraints: suffixIconConstraints ?? + const BoxConstraints( + minWidth: 34, + maxWidth: 34, + minHeight: 24, + maxHeight: 24, + )), + style: Theme.of(context).textTheme.bodyMedium, + onChanged: onChanged, + validator: validator, + ); + } +} diff --git a/lib/src/screens/address_book/widgets/wallet_contacts_list_tab_widget.dart b/lib/src/screens/address_book/widgets/wallet_contacts_list_tab_widget.dart new file mode 100644 index 0000000000..2d3b0913c6 --- /dev/null +++ b/lib/src/screens/address_book/widgets/wallet_contacts_list_tab_widget.dart @@ -0,0 +1,170 @@ +import 'package:cake_wallet/entities/contact_base.dart'; +import 'package:cake_wallet/entities/wallet_contact.dart'; +import 'package:cake_wallet/generated/i18n.dart'; +import 'package:cake_wallet/src/widgets/alert_with_two_actions.dart'; +import 'package:cake_wallet/src/widgets/standard_list.dart'; +import 'package:cake_wallet/utils/address_formatter.dart'; +import 'package:cake_wallet/utils/show_bar.dart'; +import 'package:cake_wallet/utils/show_pop_up.dart'; +import 'package:cw_core/wallet_type.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +class WalletContactsListTabWidget extends StatelessWidget { + const WalletContactsListTabWidget({required this.walletContacts, this.isEditable = false}); + + final List walletContacts; + final bool isEditable; + + @override + Widget build(BuildContext context) { + final groupedContacts = >{}; + for (var contact in walletContacts) { + final baseName = _extractBaseName(contact.name); + groupedContacts.putIfAbsent(baseName, () => []).add(contact); + } + + return ListView.builder( + itemCount: groupedContacts.length * 2, + itemBuilder: (context, index) { + if (index.isOdd) { + return StandardListSeparator(height: 0); + } else { + final groupIndex = index ~/ 2; + final groupName = groupedContacts.keys.elementAt(groupIndex); + final groupContacts = groupedContacts[groupName]!; + + if (groupContacts.length == 1) { + final contact = groupContacts[0]; + return generateRaw(context, contact, isEditable); + } else { + final activeContact = groupContacts.firstWhere( + (contact) => contact.name.contains('Active'), + orElse: () => groupContacts[0], + ); + + return Padding( + padding: const EdgeInsets.only(left: 16, right: 16, top: 4, bottom: 4), + child: ExpansionTile( + title: Text( + groupName, + style: Theme.of(context).textTheme.bodyMedium!, + ), + leading: _buildCurrencyIcon(activeContact), + tilePadding: const EdgeInsets.only(left: 16, right: 16), + childrenPadding: const EdgeInsets.only(left: 16), + expandedCrossAxisAlignment: CrossAxisAlignment.start, + expandedAlignment: Alignment.topLeft, + backgroundColor: Theme.of(context).colorScheme.surfaceContainer, + collapsedBackgroundColor: Theme.of(context).colorScheme.surfaceContainer, + collapsedShape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + children: groupContacts + .map((contact) => generateRaw(context, contact, isEditable)) + .toList(), + ), + ); + } + } + }, + ); + } +} + +String _extractBaseName(String name) { + final bracketIndex = name.indexOf('('); + return (bracketIndex != -1) ? name.substring(0, bracketIndex).trim() : name; +} + +Widget generateRaw(BuildContext context, ContactBase contact, bool isEditable) { + final currencyIcon = _buildCurrencyIcon(contact); + + return GestureDetector( + onTap: () async { + if (!isEditable) { + Navigator.of(context).pop(contact); + return; + } + + final isCopied = await DialogService.showNameAndAddressDialog(context, contact); + + if (isCopied) { + await Clipboard.setData(ClipboardData(text: contact.address)); + await showBar(context, S.of(context).copied_to_clipboard); + } + }, + behavior: HitTestBehavior.opaque, + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.all(Radius.circular(10)), + color: Theme.of(context).colorScheme.surfaceContainer, + ), + margin: const EdgeInsets.only(top: 4, bottom: 4, left: 16, right: 16), + padding: const EdgeInsets.only(top: 16, bottom: 16, right: 16, left: 16), + child: Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.start, + children: [ + currencyIcon, + Expanded( + child: Padding( + padding: EdgeInsets.only(left: 12), + child: Text( + contact.name, + style: Theme.of(context).textTheme.bodyMedium!, + ), + ), + ), + ], + ), + ), + ); +} + +Widget _buildCurrencyIcon(ContactBase contact) { + final image = contact.type.iconPath; + return image != null + ? Image.asset(image, height: 24, width: 24) + : const SizedBox(height: 24, width: 24); +} + +class DialogService { + static Future showAlertDialog(BuildContext context) async { + return await showPopUp( + context: context, + builder: (BuildContext context) { + return AlertWithTwoActions( + alertTitle: S.of(context).address_remove_contact, + alertContent: S.of(context).address_remove_content, + rightButtonText: S.of(context).remove, + leftButtonText: S.of(context).cancel, + actionRightButton: () => Navigator.of(context).pop(true), + actionLeftButton: () => Navigator.of(context).pop(false)); + }) ?? + false; + } + + static Future showNameAndAddressDialog(BuildContext context, ContactBase contact) async { + return await showPopUp( + context: context, + builder: (BuildContext context) { + return AlertWithTwoActions( + alertTitle: contact.name, + alertContent: contact.address, + alertContentTextWidget: AddressFormatter.buildSegmentedAddress( + address: contact.address, + textAlign: TextAlign.center, + walletType: cryptoCurrencyToWalletType(contact.type), + evenTextStyle: Theme.of(context).textTheme.bodyMedium!.copyWith( + fontSize: 16, + decoration: TextDecoration.none, + ), + ), + rightButtonText: S.of(context).copy, + leftButtonText: S.of(context).cancel, + actionRightButton: () => Navigator.of(context).pop(true), + actionLeftButton: () => Navigator.of(context).pop(false)); + }) ?? + false; + } +} diff --git a/lib/src/screens/buy/buy_sell_page.dart b/lib/src/screens/buy/buy_sell_page.dart index c14df4d50c..d383899834 100644 --- a/lib/src/screens/buy/buy_sell_page.dart +++ b/lib/src/screens/buy/buy_sell_page.dart @@ -2,7 +2,7 @@ import 'package:cake_wallet/buy/sell_buy_states.dart'; import 'package:cake_wallet/core/address_validator.dart'; import 'package:cake_wallet/di.dart'; import 'package:cake_wallet/entities/fiat_currency.dart'; -import 'package:cake_wallet/entities/parse_address_from_domain.dart'; +import 'package:cake_wallet/address_resolver/address_resolver_service.dart'; import 'package:cake_wallet/generated/i18n.dart'; import 'package:cake_wallet/routes.dart'; import 'package:cake_wallet/src/screens/base_page.dart'; @@ -14,7 +14,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/scollable_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'; @@ -26,9 +25,10 @@ import 'package:keyboard_actions/keyboard_actions.dart'; import 'package:mobx/mobx.dart'; class BuySellPage extends BasePage { - BuySellPage(this.buySellViewModel); + BuySellPage(this.buySellViewModel) : _resolver = getIt(); final BuySellViewModel buySellViewModel; + final AddressResolverService _resolver; final cryptoCurrencyKey = GlobalKey(); final fiatCurrencyKey = GlobalKey(); final _formKey = GlobalKey(); @@ -297,7 +297,7 @@ class BuySellPage extends BasePage { }); reaction((_) => buySellViewModel.cryptoCurrencyAddress, (String address) { - if (cryptoAddressController != address) { + if (cryptoAddressController.text != address) { cryptoCurrencyKey.currentState!.addressController.text = address; } }); @@ -321,8 +321,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; + } } }); @@ -448,8 +450,25 @@ class BuySellPage extends BasePage { addressButtonsColor: Theme.of(context).colorScheme.surfaceContainerHighest, borderColor: Theme.of(context).colorScheme.outlineVariant, addressTextFieldValidator: AddressValidator(type: buySellViewModel.cryptoCurrency), - 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.surfaceContainerLow : Theme.of(context).colorScheme.surfaceContainer, @@ -509,8 +528,10 @@ class BuySellPage extends BasePage { Future fetchParsedAddress( BuildContext context, 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 deleted file mode 100644 index cf4486ab79..0000000000 --- a/lib/src/screens/contact/contact_list_page.dart +++ /dev/null @@ -1,514 +0,0 @@ -import 'package:cake_wallet/core/auth_service.dart'; -import 'package:cake_wallet/entities/contact_base.dart'; -import 'package:cake_wallet/entities/contact_record.dart'; -import 'package:cake_wallet/entities/wallet_list_order_types.dart'; -import 'package:cake_wallet/generated/i18n.dart'; -import 'package:cake_wallet/routes.dart'; -import 'package:cake_wallet/src/screens/base_page.dart'; -import 'package:cake_wallet/src/screens/dashboard/widgets/filter_list_widget.dart'; -import 'package:cake_wallet/src/screens/wallet_list/filtered_list.dart'; -import 'package:cake_wallet/src/widgets/alert_with_two_actions.dart'; -import 'package:cake_wallet/src/widgets/standard_list.dart'; -import 'package:cake_wallet/utils/address_formatter.dart'; -import 'package:cake_wallet/utils/show_bar.dart'; -import 'package:cake_wallet/utils/show_pop_up.dart'; -import 'package:cake_wallet/view_model/contact_list/contact_list_view_model.dart'; -import 'package:cw_core/wallet_type.dart'; -import 'package:flutter/cupertino.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_slidable/flutter_slidable.dart'; - -class ContactListPage extends BasePage { - ContactListPage(this.contactListViewModel, this.authService); - - final ContactListViewModel contactListViewModel; - final AuthService authService; - - @override - String get title => S.current.address_book; - - @override - Widget? trailing(BuildContext context) { - return MergeSemantics( - child: Container( - width: 32.0, - height: 32.0, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: Theme.of(context).colorScheme.surfaceContainerHighest, - ), - child: Semantics( - label: S.of(context).add_contact, - button: true, - child: Stack( - alignment: Alignment.center, - children: [ - Icon( - Icons.add, - color: Theme.of(context).colorScheme.onSurface, - size: 22.0, - ), - ButtonTheme( - minWidth: 32.0, - height: 32.0, - child: TextButton( - // FIX-ME: Style - //shape: CircleBorder(), - onPressed: () async { - if (contactListViewModel.shouldRequireTOTP2FAForAddingContacts) { - authService.authenticateAction( - context, - route: Routes.addressBookAddContact, - conditionToDetermineIfToUse2FA: - contactListViewModel.shouldRequireTOTP2FAForAddingContacts, - ); - } else { - await Navigator.of(context).pushNamed(Routes.addressBookAddContact); - } - }, - child: Offstage(), - ), - ) - ], - ), - ), - ), - ); - } - - @override - Widget body(BuildContext context) => ContactPageBody(contactListViewModel: contactListViewModel); -} - -class ContactPageBody extends StatefulWidget { - const ContactPageBody({required this.contactListViewModel}); - - final ContactListViewModel contactListViewModel; - - @override - State createState() => _ContactPageBodyState(); -} - -class _ContactPageBodyState extends State with SingleTickerProviderStateMixin { - late TabController _tabController; - late ContactListViewModel contactListViewModel; - - @override - void initState() { - super.initState(); - _tabController = TabController(length: 2, vsync: this); - contactListViewModel = widget.contactListViewModel; - } - - @override - void dispose() { - _tabController.dispose(); - contactListViewModel.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.only(), - child: Column( - children: [ - Padding( - padding: const EdgeInsets.only(left: 24, right: 24, bottom: 8), - child: Align( - alignment: Alignment.centerLeft, - child: TabBar( - controller: _tabController, - splashFactory: NoSplash.splashFactory, - indicatorSize: TabBarIndicatorSize.label, - isScrollable: true, - labelStyle: Theme.of(context).textTheme.bodyLarge!.copyWith( - fontSize: 20, - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.primary, - ), - unselectedLabelStyle: Theme.of(context).textTheme.bodyLarge!.copyWith( - fontSize: 20, - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - indicatorColor: Theme.of(context).colorScheme.primary, - indicatorPadding: EdgeInsets.zero, - labelPadding: EdgeInsets.only(right: 24), - tabAlignment: TabAlignment.start, - dividerColor: Colors.transparent, - padding: EdgeInsets.zero, - tabs: [ - Tab(text: S.of(context).wallets), - Tab(text: S.of(context).contact_list_contacts), - ], - ), - ), - ), - Expanded( - child: TabBarView( - controller: _tabController, - children: [ - _buildWalletContacts(context), - ContactListBody( - contactListViewModel: widget.contactListViewModel, - tabController: _tabController, - ), - ], - ), - ), - ], - ), - ); - } - - Widget _buildWalletContacts(BuildContext context) { - final walletContacts = widget.contactListViewModel.walletContactsToShow; - - final groupedContacts = >{}; - for (var contact in walletContacts) { - final baseName = _extractBaseName(contact.name); - groupedContacts.putIfAbsent(baseName, () => []).add(contact); - } - - return ListView.builder( - itemCount: groupedContacts.length * 2, - itemBuilder: (context, index) { - if (index.isOdd) { - return StandardListSeparator(height: 0); - } else { - final groupIndex = index ~/ 2; - final groupName = groupedContacts.keys.elementAt(groupIndex); - final groupContacts = groupedContacts[groupName]!; - - if (groupContacts.length == 1) { - final contact = groupContacts[0]; - return generateRaw(context, contact); - } else { - final activeContact = groupContacts.firstWhere( - (contact) => contact.name.contains('Active'), - orElse: () => groupContacts[0], - ); - - return Padding( - padding: const EdgeInsets.only(left: 16, right: 16, top: 4, bottom: 4), - child: ExpansionTile( - title: Text( - groupName, - style: Theme.of(context).textTheme.bodyMedium!, - ), - leading: _buildCurrencyIcon(activeContact), - tilePadding: const EdgeInsets.only(left: 16, right: 16), - childrenPadding: const EdgeInsets.only(left: 16), - expandedCrossAxisAlignment: CrossAxisAlignment.start, - expandedAlignment: Alignment.topLeft, - backgroundColor: Theme.of(context).colorScheme.surfaceContainer, - collapsedBackgroundColor: Theme.of(context).colorScheme.surfaceContainer, - collapsedShape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), - children: groupContacts.map((contact) => generateRaw(context, contact)).toList(), - ), - ); - } - } - }, - ); - } - - String _extractBaseName(String name) { - final bracketIndex = name.indexOf('('); - return (bracketIndex != -1) ? name.substring(0, bracketIndex).trim() : name; - } - - Widget generateRaw(BuildContext context, ContactBase contact) { - final currencyIcon = _buildCurrencyIcon(contact); - - return GestureDetector( - onTap: () async { - if (!widget.contactListViewModel.isEditable) { - Navigator.of(context).pop(contact); - return; - } - - final isCopied = await DialogService.showNameAndAddressDialog(context, contact); - - if (isCopied) { - await Clipboard.setData(ClipboardData(text: contact.address)); - await showBar(context, S.of(context).copied_to_clipboard); - } - }, - behavior: HitTestBehavior.opaque, - child: Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.all(Radius.circular(10)), - color: Theme.of(context).colorScheme.surfaceContainer, - ), - margin: const EdgeInsets.only(top: 4, bottom: 4, left: 16, right: 16), - padding: const EdgeInsets.only(top: 16, bottom: 16, right: 16, left: 16), - child: Row( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.start, - children: [ - currencyIcon, - Expanded( - child: Padding( - padding: EdgeInsets.only(left: 12), - child: Text( - contact.name, - style: Theme.of(context).textTheme.bodyMedium!, - ), - ), - ), - ], - ), - ), - ); - } - - Widget _buildCurrencyIcon(ContactBase contact) { - final image = contact.type.iconPath; - return image != null - ? Image.asset(image, height: 24, width: 24) - : const SizedBox(height: 24, width: 24); - } -} - -class ContactListBody extends StatefulWidget { - ContactListBody({required this.contactListViewModel, required this.tabController}); - - final ContactListViewModel contactListViewModel; - final TabController tabController; - - @override - State createState() => _ContactListBodyState(); -} - -class _ContactListBodyState extends State { - bool _isContactsTabActive = false; - - @override - void initState() { - super.initState(); - widget.tabController.addListener(_handleTabChange); - } - - void _handleTabChange() { - setState(() { - _isContactsTabActive = widget.tabController.index == 1; - }); - } - - @override - void dispose() { - widget.tabController.removeListener(_handleTabChange); - if (widget.contactListViewModel.settingsStore.contactListOrder == FilterListOrderType.Custom) { - widget.contactListViewModel.saveCustomOrder(); - } - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final contacts = widget.contactListViewModel.isEditable - ? widget.contactListViewModel.contacts - : widget.contactListViewModel.contactsToShow; - return Scaffold( - backgroundColor: Theme.of(context).colorScheme.surface, - body: Container( - child: FilteredList( - list: contacts, - updateFunction: widget.contactListViewModel.reorderAccordingToContactList, - canReorder: widget.contactListViewModel.isEditable, - shrinkWrap: true, - itemBuilder: (context, index) { - final contact = contacts[index]; - final contactContent = - generateContactRaw(context, contact, contacts.length == index + 1); - return GestureDetector( - key: Key('${contact.name}'), - onTap: () async { - if (!widget.contactListViewModel.isEditable) { - Navigator.of(context).pop(contact); - return; - } - - final isCopied = await DialogService.showNameAndAddressDialog(context, contact); - - if (isCopied) { - await Clipboard.setData(ClipboardData(text: contact.address)); - await showBar(context, S.of(context).copied_to_clipboard); - } - }, - behavior: HitTestBehavior.opaque, - child: widget.contactListViewModel.isEditable - ? Slidable( - key: Key('${contact.key}'), - endActionPane: _actionPane(context, contact), - child: contactContent, - ) - : contactContent, - ); - }, - ), - ), - floatingActionButton: _isContactsTabActive && widget.contactListViewModel.isEditable - ? filterButtonWidget(context, widget.contactListViewModel) - : null, - ); - } - - Widget generateContactRaw(BuildContext context, ContactRecord contact, bool isLast) { - final image = contact.type.iconPath; - final currencyIcon = image != null - ? Image.asset(image, height: 24, width: 24) - : const SizedBox(height: 24, width: 24); - return Column( - children: [ - Container( - key: Key('${contact.name}'), - decoration: BoxDecoration( - borderRadius: BorderRadius.all(Radius.circular(8)), - color: Theme.of(context).colorScheme.surfaceContainer, - ), - margin: const EdgeInsets.only(top: 4, bottom: 4, left: 16, right: 16), - padding: const EdgeInsets.only(top: 16, bottom: 16, right: 16, left: 16), - child: Row( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.start, - children: [ - currencyIcon, - Expanded( - child: Padding( - padding: EdgeInsets.only(left: 12), - child: Text( - contact.name, - style: Theme.of(context).textTheme.bodyMedium!, - ), - )) - ], - ), - ), - ], - ); - } - - ActionPane _actionPane(BuildContext context, ContactRecord contact) { - return ActionPane( - motion: const ScrollMotion(), - extentRatio: 0.4, - children: [ - SlidableAction( - onPressed: (_) async => await Navigator.of(context) - .pushNamed(Routes.addressBookAddContact, arguments: contact), - backgroundColor: Theme.of(context).colorScheme.primary, - foregroundColor: Theme.of(context).colorScheme.onSurface, - icon: Icons.edit, - label: S.of(context).edit, - ), - SlidableAction( - onPressed: (_) async { - final isDelete = await DialogService.showAlertDialog(context); - - if (isDelete) { - await widget.contactListViewModel.delete(contact); - } - }, - backgroundColor: Theme.of(context).colorScheme.error, - foregroundColor: Theme.of(context).colorScheme.onSurface, - icon: CupertinoIcons.delete, - label: S.of(context).delete, - ), - ], - ); - } - - Widget filterButtonWidget(BuildContext context, ContactListViewModel contactListViewModel) { - final filterIcon = Image.asset( - 'assets/images/filter_icon.png', - color: Theme.of(context).colorScheme.onSurface, - ); - return MergeSemantics( - child: SizedBox( - height: 58, - width: 58, - child: ButtonTheme( - minWidth: double.minPositive, - child: Semantics( - container: true, - child: GestureDetector( - onTap: () async { - await showPopUp( - context: context, - builder: (context) => FilterListWidget( - initalType: contactListViewModel.orderType, - initalAscending: contactListViewModel.ascending, - onClose: (bool ascending, FilterListOrderType type) async { - contactListViewModel.setAscending(ascending); - await contactListViewModel.setOrderType(type); - }, - ), - ); - }, - child: Semantics( - label: 'Transaction Filter', - button: true, - enabled: true, - child: Container( - height: 36, - width: 36, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: Theme.of(context).colorScheme.surfaceContainer, - ), - child: filterIcon, - ), - ), - ), - ), - ), - ), - ); - } -} - -class DialogService { - static Future showAlertDialog(BuildContext context) async { - return await showPopUp( - context: context, - builder: (BuildContext context) { - return AlertWithTwoActions( - alertTitle: S.of(context).address_remove_contact, - alertContent: S.of(context).address_remove_content, - rightButtonText: S.of(context).remove, - leftButtonText: S.of(context).cancel, - actionRightButton: () => Navigator.of(context).pop(true), - actionLeftButton: () => Navigator.of(context).pop(false)); - }) ?? - false; - } - - static Future showNameAndAddressDialog(BuildContext context, ContactBase contact) async { - return await showPopUp( - context: context, - builder: (BuildContext context) { - return AlertWithTwoActions( - alertTitle: contact.name, - alertContent: contact.address, - alertContentTextWidget: AddressFormatter.buildSegmentedAddress( - address: contact.address, - textAlign: TextAlign.center, - walletType: cryptoCurrencyToWalletType(contact.type), - evenTextStyle: Theme.of(context).textTheme.bodyMedium!.copyWith( - fontSize: 16, - decoration: TextDecoration.none, - ), - ), - rightButtonText: S.of(context).copy, - leftButtonText: S.of(context).cancel, - actionRightButton: () => Navigator.of(context).pop(true), - actionLeftButton: () => Navigator.of(context).pop(false)); - }) ?? - false; - } -} diff --git a/lib/src/screens/contact/contact_page.dart b/lib/src/screens/contact/contact_page.dart deleted file mode 100644 index 2b54736ccb..0000000000 --- a/lib/src/screens/contact/contact_page.dart +++ /dev/null @@ -1,190 +0,0 @@ -import 'package:cake_wallet/core/address_validator.dart'; -import 'package:cake_wallet/utils/show_pop_up.dart'; -import 'package:cw_core/currency.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_mobx/flutter_mobx.dart'; -import 'package:mobx/mobx.dart'; -import 'package:cake_wallet/generated/i18n.dart'; -import 'package:cake_wallet/core/contact_name_validator.dart'; -import 'package:cake_wallet/core/execution_state.dart'; -import 'package:cake_wallet/view_model/contact_list/contact_view_model.dart'; -import 'package:cw_core/crypto_currency.dart'; -import 'package:cake_wallet/src/screens/base_page.dart'; -import 'package:cake_wallet/src/widgets/alert_with_one_action.dart'; -import 'package:cake_wallet/src/widgets/primary_button.dart'; -import 'package:cake_wallet/src/widgets/address_text_field.dart'; -import 'package:cake_wallet/src/widgets/scollable_with_bottom_section.dart'; -import 'package:cake_wallet/src/widgets/base_text_form_field.dart'; -import 'package:cake_wallet/src/screens/exchange/widgets/currency_picker.dart'; - -class ContactPage extends BasePage { - ContactPage(this.contactViewModel) - : _formKey = GlobalKey(), - _nameController = TextEditingController(), - _addressController = TextEditingController(), - _currencyTypeController = TextEditingController() { - _nameController.text = contactViewModel.name; - _addressController.text = contactViewModel.address; - _nameController.addListener(() => contactViewModel.name = _nameController.text); - _addressController.addListener(() => contactViewModel.address = _addressController.text); - - autorun((_) => _currencyTypeController.text = contactViewModel.currency?.toString() ?? ''); - } - - @override - String get title => S.current.contact; - - final ContactViewModel contactViewModel; - final GlobalKey _formKey; - final TextEditingController _nameController; - final TextEditingController _currencyTypeController; - final TextEditingController _addressController; - bool _isEffectsApplied = false; - - @override - Widget body(BuildContext context) { - final downArrow = Image.asset( - 'assets/images/arrow_bottom_purple_icon.png', - color: Theme.of(context).colorScheme.primary, - height: 8, - ); - - _setEffects(context); - - return Observer( - builder: (_) => ScrollableWithBottomSection( - contentPadding: EdgeInsets.all(24), - content: Form( - key: _formKey, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - BaseTextFormField( - controller: _nameController, - hintText: S.of(context).contact_name, - validator: ContactNameValidator(), - ), - Padding( - padding: EdgeInsets.only(top: 20), - child: Container( - child: InkWell( - onTap: () => _presentCurrencyPicker(context), - child: IgnorePointer( - child: BaseTextFormField( - controller: _currencyTypeController, - hintText: S.of(context).settings_currency, - suffixIcon: Row( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.end, - children: [ - downArrow, - SizedBox(width: 16), - ], - ), - ), - ), - ), - ), - ), - if (contactViewModel.currency != null) - Padding( - padding: EdgeInsets.only(top: 20), - child: AddressTextField( - controller: _addressController, - options: [ - AddressTextFieldOption.paste, - AddressTextFieldOption.qrCode, - ], - buttonColor: Theme.of(context).colorScheme.surfaceContainerHighest, - iconColor: Theme.of(context).colorScheme.onSurfaceVariant, - validator: AddressValidator(type: contactViewModel.currency!), - ), - ) - ], - ), - ), - bottomSectionPadding: EdgeInsets.only(left: 24, right: 24, bottom: 24), - bottomSection: Row( - children: [ - Expanded( - child: PrimaryButton( - onPressed: () { - contactViewModel.reset(); - _nameController.text = ''; - _addressController.text = ''; - }, - text: S.of(context).reset, - color: Theme.of(context).colorScheme.surfaceContainer, - textColor: Theme.of(context).colorScheme.onSecondaryContainer, - ), - ), - SizedBox(width: 20), - Expanded( - child: Observer( - builder: (_) => PrimaryButton( - onPressed: () async { - if (_formKey.currentState != null && !_formKey.currentState!.validate()) { - return; - } - - await contactViewModel.save(); - }, - text: S.of(context).save, - color: Theme.of(context).colorScheme.primary, - textColor: Theme.of(context).colorScheme.onPrimary, - isDisabled: !contactViewModel.isReady, - ), - ), - ) - ], - )), - ); - } - - void _presentCurrencyPicker(BuildContext context) { - showPopUp( - builder: (_) => CurrencyPicker( - selectedAtIndex: contactViewModel.currency != null - ? contactViewModel.currencies.indexOf(contactViewModel.currency!) - : -1, - items: contactViewModel.currencies, - title: S.of(context).please_select, - hintText: S.of(context).search_currency, - onItemSelected: (Currency item) => contactViewModel.currency = item as CryptoCurrency), - context: context, - ); - } - - void _onContactSavingFailure(BuildContext context, String error) { - showPopUp( - context: context, - builder: (BuildContext context) { - return AlertWithOneAction( - alertTitle: S.current.contact, - alertContent: error, - buttonText: S.of(context).ok, - buttonAction: () => Navigator.of(context).pop(), - ); - }); - } - - void _onContactSavedSuccessfully(BuildContext context) => Navigator.of(context).pop(); - - void _setEffects(BuildContext context) { - if (_isEffectsApplied) { - return; - } - - _isEffectsApplied = true; - - reaction((_) => contactViewModel.state, (ExecutionState state) { - if (state is FailureState) { - _onContactSavingFailure(context, state.error); - } - - if (state is ExecutedSuccessfullyState) { - _onContactSavedSuccessfully(context); - } - }); - } -} diff --git a/lib/src/screens/dashboard/widgets/filter_list_widget.dart b/lib/src/screens/dashboard/widgets/filter_list_widget.dart index c7a2067dee..e7dbe61c80 100644 --- a/lib/src/screens/dashboard/widgets/filter_list_widget.dart +++ b/lib/src/screens/dashboard/widgets/filter_list_widget.dart @@ -12,11 +12,13 @@ class FilterListWidget extends StatefulWidget { required this.initalType, required this.initalAscending, required this.onClose, + this.showGroupByType = true, }); final FilterListOrderType? initalType; final bool initalAscending; final Function(bool, FilterListOrderType) onClose; + final bool showGroupByType; @override FilterListWidgetState createState() => FilterListWidgetState(); @@ -110,6 +112,7 @@ class FilterListWidgetState extends State { onChanged: setSelectedOrderType, activeColor: Theme.of(context).colorScheme.primary, ), + if (widget.showGroupByType) RadioListTile( value: FilterListOrderType.GroupByType, groupValue: type, diff --git a/lib/src/screens/dashboard/widgets/sign_form.dart b/lib/src/screens/dashboard/widgets/sign_form.dart index 9fe661cdaf..a77955e78e 100644 --- a/lib/src/screens/dashboard/widgets/sign_form.dart +++ b/lib/src/screens/dashboard/widgets/sign_form.dart @@ -73,7 +73,7 @@ class SignFormState extends State with AutomaticKeepAliveClientMixin { ], buttonColor: Theme.of(context).colorScheme.surfaceContainerHighest, onSelectedContact: (contact) { - addressController.text = contact.address; + addressController.text = contact.$2; }, selectedCurrency: walletTypeToCryptoCurrency(widget.type), fillColor: Theme.of(context).colorScheme.surface, diff --git a/lib/src/screens/dashboard/widgets/verify_form.dart b/lib/src/screens/dashboard/widgets/verify_form.dart index 97e94015cc..3ff5213097 100644 --- a/lib/src/screens/dashboard/widgets/verify_form.dart +++ b/lib/src/screens/dashboard/widgets/verify_form.dart @@ -63,7 +63,7 @@ class VerifyFormState extends State with AutomaticKeepAliveClientMix buttonColor: Theme.of(context).colorScheme.surfaceContainerHighest, fillColor: Theme.of(context).colorScheme.surface, onSelectedContact: (contact) { - addressController.text = contact.address; + addressController.text = contact.$2; }, selectedCurrency: walletTypeToCryptoCurrency(widget.type), ), diff --git a/lib/src/screens/exchange/exchange_page.dart b/lib/src/screens/exchange/exchange_page.dart index db0114a87a..d149cff257 100644 --- a/lib/src/screens/exchange/exchange_page.dart +++ b/lib/src/screens/exchange/exchange_page.dart @@ -12,8 +12,7 @@ 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:cw_core/wallet_type.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/address_resolver/address_resolver_service.dart'; import 'package:cake_wallet/src/widgets/standard_checkbox.dart'; import 'package:flutter/material.dart'; import 'package:flutter_mobx/flutter_mobx.dart'; @@ -42,7 +41,8 @@ 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.initialPaymentRequest) + : _resolver = getIt() { depositWalletName = exchangeViewModel.depositCurrency == CryptoCurrency.xmr ? exchangeViewModel.wallet.name : null; @@ -52,6 +52,7 @@ class ExchangePage extends BasePage { } final ExchangeViewModel exchangeViewModel; + final AddressResolverService _resolver; final AuthService authService; final PaymentRequest? initialPaymentRequest; final depositKey = GlobalKey(); @@ -586,17 +587,25 @@ class ExchangePage extends BasePage { _depositAddressFocus.addListener(() async { if (!_depositAddressFocus.hasFocus && depositAddressController.text.isNotEmpty) { final domain = depositAddressController.text; - exchangeViewModel.depositAddress = - await fetchParsedAddress(context, domain, exchangeViewModel.depositCurrency); + final parsedAddress = + await fetchParsedAddress(context, domain, exchangeViewModel.receiveCurrency); + if (parsedAddress.isNotEmpty) { + exchangeViewModel.depositAddress = + await fetchParsedAddress(context, domain, exchangeViewModel.depositCurrency); + } } }); _receiveAddressFocus.addListener(() async { if (!_receiveAddressFocus.hasFocus && receiveAddressController.text.isNotEmpty) { final domain = receiveAddressController.text; + final parsedAddress = + await fetchParsedAddress(context, domain, exchangeViewModel.receiveCurrency); + if(parsedAddress.isNotEmpty) { exchangeViewModel.receiveAddress = await fetchParsedAddress(context, domain, exchangeViewModel.receiveCurrency); } + } }); _receiveAmountFocus.addListener(() { @@ -658,8 +667,11 @@ 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 _resolver.resolve( + query: domain, + wallet: exchangeViewModel.wallet, + currency: currency); + return parsedAddresses.isNotEmpty ? parsedAddresses.first.parsedAddressByCurrencyMap[currency] ?? '' : ''; } void _showFeeAlert(BuildContext context) async { @@ -788,15 +800,17 @@ class ExchangePage extends BasePage { : null; }, addressTextFieldValidator: AddressValidator(type: exchangeViewModel.receiveCurrency), + onSelectedContact: (contact) { + exchangeViewModel.receiveAddress = contact.$2; + }, onPushPasteButton: (context) async { final domain = exchangeViewModel.receiveAddress; - exchangeViewModel.receiveAddress = - await fetchParsedAddress(context, domain, exchangeViewModel.receiveCurrency); + final parsedAddress = + await fetchParsedAddress(context, domain, exchangeViewModel.receiveCurrency); + if (parsedAddress.isNotEmpty) exchangeViewModel.receiveAddress = parsedAddress; }, onPushAddressBookButton: (context) async { - final domain = exchangeViewModel.receiveAddress; - exchangeViewModel.receiveAddress = - await fetchParsedAddress(context, domain, exchangeViewModel.receiveCurrency); + exchangeViewModel.receiveAddress = ''; }, ), ); diff --git a/lib/src/screens/exchange/widgets/exchange_card.dart b/lib/src/screens/exchange/widgets/exchange_card.dart index 12b4748b7b..96b4e9e032 100644 --- a/lib/src/screens/exchange/widgets/exchange_card.dart +++ b/lib/src/screens/exchange/widgets/exchange_card.dart @@ -44,6 +44,7 @@ class ExchangeCard extends StatefulWidget { this.allAmount, this.currencyRowPadding, this.addressRowPadding, + this.onSelectedContact, this.onPushPasteButton, this.onPushAddressBookButton, this.onDispose, @@ -77,6 +78,7 @@ class ExchangeCard extends StatefulWidget { final VoidCallback? allAmount; final EdgeInsets? currencyRowPadding; final EdgeInsets? addressRowPadding; + final Function((String,String))? onSelectedContact; final void Function(BuildContext context)? onPushPasteButton; final void Function(BuildContext context)? onPushAddressBookButton; final Function()? onDispose; @@ -330,6 +332,7 @@ class ExchangeCardState extends State> { ), buttonColor: widget.addressButtonsColor, validator: widget.addressTextFieldValidator, + onSelectedContact: widget.onSelectedContact, onPushPasteButton: widget.onPushPasteButton, onPushAddressBookButton: widget.onPushAddressBookButton, selectedCurrency: _selectedCurrency, diff --git a/lib/src/screens/exchange_trade/exchange_trade_page.dart b/lib/src/screens/exchange_trade/exchange_trade_page.dart index 17b6fd71cc..ad3d3961d7 100644 --- a/lib/src/screens/exchange_trade/exchange_trade_page.dart +++ b/lib/src/screens/exchange_trade/exchange_trade_page.dart @@ -315,9 +315,10 @@ class ExchangeTradeState extends State { }); } + if (state is TransactionCommitted) { WidgetsBinding.instance.addPostFrameCallback( - (_) async { + (_) async { if (!mounted) return; await showModalBottomSheet( @@ -336,7 +337,7 @@ class ExchangeTradeState extends State { if (mounted) { Navigator.of(context).pushNamedAndRemoveUntil( Routes.dashboard, - (route) => false, + (route) => false, ); } RequestReviewHandler.requestReview(); diff --git a/lib/src/screens/integrations/deuro/savings_page.dart b/lib/src/screens/integrations/deuro/savings_page.dart index 8aee7fd835..01aa137075 100644 --- a/lib/src/screens/integrations/deuro/savings_page.dart +++ b/lib/src/screens/integrations/deuro/savings_page.dart @@ -5,6 +5,7 @@ import 'package:cake_wallet/src/screens/integrations/deuro/widgets/info_chip.dar import 'package:cake_wallet/src/screens/integrations/deuro/widgets/interest_card_widget.dart'; import 'package:cake_wallet/src/screens/integrations/deuro/widgets/savings_card_widget.dart'; import 'package:cake_wallet/src/screens/integrations/deuro/widgets/savings_edit_sheet.dart'; +import 'package:cake_wallet/src/widgets/bottom_sheet/base_bottom_sheet_widget.dart'; import 'package:cake_wallet/src/widgets/alert_with_one_action.dart'; import 'package:cake_wallet/src/widgets/bottom_sheet/base_bottom_sheet_widget.dart'; import 'package:cake_wallet/src/widgets/bottom_sheet/confirm_sending_bottom_sheet_widget.dart'; diff --git a/lib/src/screens/receive/widgets/qr_widget.dart b/lib/src/screens/receive/widgets/qr_widget.dart index 140c5619f6..e59940d2ee 100644 --- a/lib/src/screens/receive/widgets/qr_widget.dart +++ b/lib/src/screens/receive/widgets/qr_widget.dart @@ -1,4 +1,5 @@ import 'package:cake_wallet/entities/qr_view_data.dart'; +import 'package:cake_wallet/src/widgets/bottom_sheet/base_bottom_sheet_widget.dart'; import 'package:cake_wallet/src/widgets/primary_button.dart'; import 'package:cake_wallet/routes.dart'; import 'package:cake_wallet/src/screens/exchange/widgets/currency_picker.dart'; diff --git a/lib/src/screens/send/send_page.dart b/lib/src/screens/send/send_page.dart index 9bc18dc2ec..65796cb119 100644 --- a/lib/src/screens/send/send_page.dart +++ b/lib/src/screens/send/send_page.dart @@ -1,3 +1,4 @@ +import 'package:cake_wallet/address_resolver/parsed_address.dart'; import 'package:cake_wallet/bitcoin/bitcoin.dart'; import 'package:cake_wallet/core/address_validator.dart'; import 'package:cake_wallet/core/auth_service.dart'; @@ -19,6 +20,7 @@ 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/bottom_sheet/base_bottom_sheet_widget.dart'; import 'package:cake_wallet/src/widgets/bottom_sheet/confirm_sending_bottom_sheet_widget.dart'; +import 'package:cake_wallet/src/widgets/bottom_sheet/contact_bottom_sheet_widget.dart'; import 'package:cake_wallet/src/widgets/bottom_sheet/info_bottom_sheet_widget.dart'; import 'package:cake_wallet/src/widgets/keyboard_done_button.dart'; import 'package:cake_wallet/src/widgets/picker.dart'; @@ -64,7 +66,7 @@ class SendPage extends BasePage { final PaymentRequest? initialPaymentRequest; bool _effectsInstalled = false; - ContactRecord? newContactAddress; + String? newContactAddress; @override String get title => S.current.send; @@ -614,8 +616,7 @@ class SendPage extends BasePage { newContactAddress = newContactAddress ?? sendViewModel.newContactAddress(); - if (newContactAddress?.address != null && - isRegularElectrumAddress(newContactAddress!.address)) { + if (newContactAddress != null && isRegularElectrumAddress(newContactAddress!)) { newContactAddress = null; } @@ -670,12 +671,31 @@ class SendPage extends BasePage { RequestReviewHandler.requestReview(); newContactAddress = null; }, - onRightActionButtonPressed: () { + onRightActionButtonPressed: () async { Navigator.of(bottomSheetContext).pop(); RequestReviewHandler.requestReview(); if (context.mounted) { - Navigator.of(context).pushNamed(Routes.addressBookAddContact, - arguments: newContactAddress); + await showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (BuildContext bottomSheetContext) { + + final newParsedAddress = ParsedAddress( + parsedAddressByCurrencyMap: {}, + manualAddressByCurrencyMap: {sendViewModel.selectedCryptoCurrency: newContactAddress ?? ''}, + addressSource: AddressSource.contact, + handle: '', + profileName: 'New Contact', + profileImageUrl: 'assets/images/profile.png', + description: '', + ); + + return AddressBookBottomSheet( + initialRoute: Routes.editNewContactPage, + initialArgs: [newParsedAddress] + ); + }, + ); } newContactAddress = null; }, diff --git a/lib/src/screens/send/widgets/choose_yat_address_alert.dart b/lib/src/screens/send/widgets/choose_yat_address_alert.dart deleted file mode 100644 index 83f6cbd4b3..0000000000 --- a/lib/src/screens/send/widgets/choose_yat_address_alert.dart +++ /dev/null @@ -1,117 +0,0 @@ -import 'package:cake_wallet/src/widgets/cake_scrollbar.dart'; -import 'package:cake_wallet/src/widgets/section_divider.dart'; -import 'package:flutter/material.dart'; -import 'package:cake_wallet/src/widgets/base_alert_dialog.dart'; - -class ChooseYatAddressAlert extends BaseAlertDialog { - ChooseYatAddressAlert({ - required this.alertTitle, - required this.alertContent, - required this.addresses, - }); - - final String alertTitle; - final String alertContent; - final List addresses; - - @override - String get titleText => alertTitle; - - @override - String get contentText => alertContent; - - @override - bool get barrierDismissible => false; - - @override - Widget actionButtons(BuildContext context) => ChooseYatAddressButtons(addresses); -} - -class ChooseYatAddressButtons extends StatefulWidget { - ChooseYatAddressButtons(this.addresses); - - final List addresses; - - @override - ChooseYatAddressButtonsState createState() => ChooseYatAddressButtonsState(addresses); -} - -class ChooseYatAddressButtonsState extends State { - ChooseYatAddressButtonsState(this.addresses) : itemCount = addresses?.length ?? 0; - - final List addresses; - final int itemCount; - final double backgroundHeight = 118; - final double thumbHeight = 72; - ScrollController controller = ScrollController(); - double fromTop = 0; - - @override - Widget build(BuildContext context) { - controller.addListener(() { - fromTop = controller.hasClients - ? (controller.offset / - controller.position.maxScrollExtent * - (backgroundHeight - thumbHeight)) - : 0; - setState(() {}); - }); - - return Stack( - alignment: Alignment.center, - clipBehavior: Clip.none, - children: [ - Container( - width: 300, - height: 158, - color: Theme.of(context).colorScheme.surface, - child: ListView.separated( - controller: controller, - padding: EdgeInsets.all(0), - itemCount: itemCount, - separatorBuilder: (_, __) => const HorizontalSectionDivider(), - itemBuilder: (context, index) { - final address = addresses[index]; - - return GestureDetector( - onTap: () => Navigator.of(context).pop(address), - child: Container( - width: 300, - height: 52, - padding: EdgeInsets.only(left: 24, right: 24), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Expanded( - child: Text( - address, - textAlign: TextAlign.center, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodyMedium!.copyWith( - fontSize: 15, - fontWeight: FontWeight.w600, - - color: Theme.of(context).colorScheme.onSurfaceVariant, - decoration: TextDecoration.none, - ), - ), - ) - ], - ), - ), - ); - }, - ), - ), - if (itemCount > 3) - CakeScrollbar( - backgroundHeight: backgroundHeight, - thumbHeight: thumbHeight, - fromTop: fromTop, - ) - ], - ); - } -} 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 b37f87b7ff..0000000000 --- a/lib/src/screens/send/widgets/extract_address_from_parsed.dart +++ /dev/null @@ -1,135 +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.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.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.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 c12f0c1b0f..586c940821 100644 --- a/lib/src/screens/send/widgets/send_card.dart +++ b/lib/src/screens/send/widgets/send_card.dart @@ -348,6 +348,7 @@ class SendCardState extends State with AutomaticKeepAliveClientMixin with AutomaticKeepAliveClientMixin output.loadContact(contact), validator: validator, selectedCurrency: sendViewModel.selectedCryptoCurrency, ); diff --git a/lib/src/screens/settings/domain_lookups_page.dart b/lib/src/screens/settings/domain_lookups_page.dart index ed5916437d..a54216f6b9 100644 --- a/lib/src/screens/settings/domain_lookups_page.dart +++ b/lib/src/screens/settings/domain_lookups_page.dart @@ -21,6 +21,10 @@ class DomainLookupsPage extends BasePage { padding: EdgeInsets.only(top: 10), child: Column( children: [ + SettingsSwitcherCell( + title: S.current.all, + value: _privacySettingsViewModel.allLookups, + onValueChange: (_, bool value) => _privacySettingsViewModel.setAllLookups(value)), SettingsSwitcherCell( title: 'Twitter', value: _privacySettingsViewModel.lookupTwitter, @@ -53,8 +57,22 @@ class DomainLookupsPage extends BasePage { title: 'Zano Aliases', value: _privacySettingsViewModel.lookupsZanoAlias, onValueChange: (_, bool value) => _privacySettingsViewModel.setLookupsZanoAlias(value)), - - //if (!isHaven) it does not work correctly + SettingsSwitcherCell( + title: 'FIO', + value: _privacySettingsViewModel.lookupsFio, + onValueChange: (_, bool value) => _privacySettingsViewModel.setLookupsFio(value)), + SettingsSwitcherCell( + title: 'Nostr', + value: _privacySettingsViewModel.lookupsNostr, + onValueChange: (_, bool value) => _privacySettingsViewModel.setLookupsNostr(value)), + SettingsSwitcherCell( + title: 'ThorChain', + value: _privacySettingsViewModel.lookupsThorChain, + onValueChange: (_, bool value) => _privacySettingsViewModel.setLookupsThorChain(value)), + SettingsSwitcherCell( + title: 'BIP-353', + value: _privacySettingsViewModel.lookupsBip353, + onValueChange: (_, bool value) => _privacySettingsViewModel.setLookupsBip353(value)), ], ), ); diff --git a/lib/src/screens/wallet_list/filtered_list.dart b/lib/src/screens/wallet_list/filtered_list.dart index 5316c84729..872bdfb710 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.itemPadding = const EdgeInsets.symmetric(vertical: 4), }); final ObservableList list; @@ -18,39 +19,51 @@ class FilteredList extends StatefulWidget { final bool canReorder; final bool shrinkWrap; final ScrollPhysics? physics; + final EdgeInsets itemPadding; @override FilteredListState createState() => FilteredListState(); } class FilteredListState extends State { + Widget _buildPaddedItem(BuildContext ctx, int index) { + return Padding( + key: ValueKey(widget.list[index]), + padding: widget.itemPadding, + child: widget.itemBuilder(ctx, index), + ); + } + @override Widget build(BuildContext context) { - if (widget.canReorder) { - return Observer( - builder: (_) => ReorderableListView.builder( - shrinkWrap: widget.shrinkWrap, - physics: widget.physics ?? const BouncingScrollPhysics(), - itemBuilder: widget.itemBuilder, - itemCount: widget.list.length, - onReorder: (int oldIndex, int newIndex) { - if (oldIndex < newIndex) { - newIndex -= 1; - } - final dynamic item = widget.list.removeAt(oldIndex); - widget.list.insert(newIndex, item); - widget.updateFunction(); - }, - ), - ); - } else { + if (!widget.canReorder) { return Observer( builder: (_) => ListView.builder( + shrinkWrap: widget.shrinkWrap, physics: widget.physics ?? const BouncingScrollPhysics(), - itemBuilder: widget.itemBuilder, itemCount: widget.list.length, + itemBuilder: _buildPaddedItem, ), ); } + + return Observer( + builder: (_) => ReorderableListView.builder( + shrinkWrap: widget.shrinkWrap, + physics: widget.physics ?? const BouncingScrollPhysics(), + itemCount: widget.list.length, + itemBuilder: _buildPaddedItem, + onReorder: (oldIndex, newIndex) { + if (oldIndex < newIndex) newIndex -= 1; + final item = widget.list.removeAt(oldIndex); + widget.list.insert(newIndex, item); + widget.updateFunction(); + }, + proxyDecorator: (child, _, __) => Material( + color: Colors.transparent, + child: child, + ), + ), + ); } } diff --git a/lib/src/widgets/address_text_field.dart b/lib/src/widgets/address_text_field.dart index 6a0084652c..a9d5c29a94 100644 --- a/lib/src/widgets/address_text_field.dart +++ b/lib/src/widgets/address_text_field.dart @@ -1,5 +1,8 @@ +import 'package:cake_wallet/entities/contact.dart'; import 'package:cake_wallet/entities/contact_base.dart'; +import 'package:cake_wallet/entities/contact_record.dart'; import 'package:cake_wallet/entities/qr_scanner.dart'; +import 'package:cake_wallet/entities/wallet_contact.dart'; import 'package:cake_wallet/generated/i18n.dart'; import 'package:cake_wallet/routes.dart'; import 'package:cake_wallet/src/widgets/base_text_form_field.dart'; @@ -36,6 +39,7 @@ class AddressTextField extends StatelessWidget { this.hasUnderlineBorder = false, this.borderWidth = 1.0, this.contentPadding, + this.keyboardType, this.copyImagePath, }); @@ -61,12 +65,13 @@ class AddressTextField extends StatelessWidget { final FocusNode? focusNode; final T? selectedCurrency; final Key? addressKey; + final TextInputType? keyboardType; final String? copyImagePath; final Function(BuildContext context)? onPushPasteButton; final Function(BuildContext context)? onPushAddressBookButton; final Function(BuildContext context)? onPushAddressPickerButton; - final Function(ContactBase contact)? onSelectedContact; + final Function((String,String))? onSelectedContact; @override Widget build(BuildContext context) { @@ -78,7 +83,7 @@ class AddressTextField extends StatelessWidget { hasUnderlineBorder: hasUnderlineBorder, key: addressKey, enableIMEPersonalizedLearning: false, - keyboardType: TextInputType.visiblePassword, + keyboardType: keyboardType ?? TextInputType.visiblePassword, onFieldSubmitted: (_) => FocusScope.of(context).unfocus(), enabled: isActive, controller: controller, @@ -241,13 +246,19 @@ class AddressTextField extends StatelessWidget { } Future _presetAddressBookPicker(BuildContext context) async { - final contact = await Navigator.of(context) + var contact = await Navigator.of(context) .pushNamed(Routes.pickerAddressBook, arguments: selectedCurrency); - if (contact is ContactBase) { + if( contact is WalletContact) { controller?.text = contact.address; onPushAddressBookButton?.call(context); - onSelectedContact?.call(contact); + onSelectedContact?.call((contact.name, contact.address)); + } + + if (contact is (ContactRecord,String)) { + controller?.text = contact.$2; + onPushAddressBookButton?.call(context); + onSelectedContact?.call((contact.$1.name, contact.$2)); } } diff --git a/lib/src/widgets/base_alert_dialog.dart b/lib/src/widgets/base_alert_dialog.dart index a27f797246..fba0e2e79e 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:cake_wallet/src/widgets/section_divider.dart'; +import 'package:cake_wallet/utils/image_utill.dart'; import 'package:flutter/material.dart'; class BaseAlertDialog extends StatelessWidget { @@ -158,10 +159,7 @@ class BaseAlertDialog extends StatelessWidget { radius: 50, backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest, child: ClipOval( - child: Image.network( - imageUrl, - fit: BoxFit.cover, - ), + child: ImageUtil.getImageFromPath(imagePath: imageUrl, 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 47cacea1e2..d78988b3f8 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 @@ -157,7 +157,7 @@ class ConfirmSendingBottomSheet extends BaseBottomSheet { itemBuilder: (context, index) { final bool isBatchSending = outputs.length > 1; final item = outputs[index]; - final contactName = item.parsedAddress.name; + final contactName = item.parsedAddress.handle; final isCakePayName = contactName == 'Cake Pay'; final batchContactTitle = '${index + 1}/${outputs.length} - ${contactName.isEmpty ? 'Address' : contactName}'; diff --git a/lib/src/widgets/bottom_sheet/contact_bottom_sheet_widget.dart b/lib/src/widgets/bottom_sheet/contact_bottom_sheet_widget.dart new file mode 100644 index 0000000000..99b1e66dc3 --- /dev/null +++ b/lib/src/widgets/bottom_sheet/contact_bottom_sheet_widget.dart @@ -0,0 +1,168 @@ +import 'dart:async'; + +import 'package:cake_wallet/di.dart'; +import 'package:cake_wallet/entities/contact_record.dart'; +import 'package:cake_wallet/address_resolver/parsed_address.dart'; +import 'package:cake_wallet/routes.dart'; +import 'package:cake_wallet/src/screens/address_book/contact_refresh_page.dart'; +import 'package:cake_wallet/src/screens/address_book/contact_welcome_page.dart'; +import 'package:cake_wallet/src/screens/address_book/edit_address_page.dart'; +import 'package:cake_wallet/src/screens/address_book/edit_alias_page.dart'; +import 'package:cake_wallet/src/screens/address_book/edit_contact_page.dart'; +import 'package:cake_wallet/src/screens/address_book/contact_page.dart'; +import 'package:cake_wallet/src/screens/address_book/edit_new_contact_page.dart'; +import 'package:cake_wallet/src/screens/address_book/supported_handles_page.dart'; +import 'package:cake_wallet/view_model/contact_list/contact_view_model.dart'; +import 'package:cw_core/crypto_currency.dart'; +import 'package:flutter/material.dart'; + +class AddressBookBottomSheet extends StatefulWidget { + const AddressBookBottomSheet({ + this.initialRoute, + this.initialArgs + }); + + final String? initialRoute; + final Object? initialArgs; + + @override + State createState() => _AddressBookBottomSheetState(); +} + +class _AddressBookBottomSheetState extends State + with TickerProviderStateMixin { + @override + Widget build(BuildContext context) { + final double screenH = MediaQuery.of(context).size.height; + return ClipRRect( + borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), + child: Material( + color: Theme.of(context).colorScheme.surface, + child: ConstrainedBox( + constraints: BoxConstraints(minHeight: screenH * 0.45), + child: AnimatedSize( + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + alignment: Alignment.topCenter, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _buildDragHandle(context), + _AddContactNavigator( + initialRoute: widget.initialRoute ?? Navigator.defaultRouteName, + initialArgs: widget.initialArgs, + ), + ], + ), + ), + ), + ), + ); + } +} + +Widget _buildDragHandle(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(top: 10, bottom: 6), + child: Row( + children: [ + const Spacer(flex: 4), + Expanded( + flex: 2, + child: Container( + height: 4, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(4), + color: Theme.of(context).colorScheme.onSurface, + ), + ), + ), + const Spacer(flex: 4), + ], + ), + ); +} + +class _AddContactNavigator extends StatelessWidget { + const _AddContactNavigator({ + required this.initialRoute, + this.initialArgs + }); + + final String initialRoute; + final Object? initialArgs; + + @override + Widget build(BuildContext context) { + return Navigator( + onGenerateInitialRoutes: (_, __) => [ + _routeFor(initialRoute, initialArgs), + ], + onGenerateRoute: (settings) => _routeFor( + settings.name ?? Navigator.defaultRouteName, + settings.arguments, + ), + ); + } + + Route _routeFor(String name, Object? args) { + final Widget page = _pageFor(name, args); + + return PageRouteBuilder( + settings: RouteSettings(name: name, arguments: args), + pageBuilder: (_, __, ___) => page, + transitionDuration: const Duration(milliseconds: 150), + reverseTransitionDuration: const Duration(milliseconds: 150), + transitionsBuilder: (_, animation, __, child) { + final slide = Tween( + begin: const Offset(0, 0.08), + end: Offset.zero, + ).animate(CurvedAnimation( + parent: animation, + curve: Curves.easeOutCubic, + )); + + return SlideTransition(position: slide, child: child); + }, + ); + } + + Widget _pageFor(String name, Object? args) { + switch (name) { + case Routes.supportedHandlesPage: + return getIt(); + case Routes.contactWelcomePage: + return getIt(param1: args as ContactRecord?); + case Routes.editNewContactPage: + final list = args as List; + return getIt( + param1: list[0] as ParsedAddress, + param2: list.length > 1 ? list[1] as ContactRecord? : null, + ); + case Routes.editContactPage: + final vm = args as ContactViewModel; + return getIt(param1: vm); + case Routes.editAliasPage: + final list = args as List; + return getIt( + param1: list[0] as ContactViewModel, + param2: list.length > 1 ? list[1] as String? : '', + ); + case Routes.contactPage: + return getIt(param1: args as ContactRecord); + case Routes.editAddressPage: + return getIt(param1: args); + + case Routes.contactRefreshPage: + final list = args as List; + final contact = list[0] as ContactRecord; + final selectedCurrency = list[1] as CryptoCurrency; + return getIt( + param1: contact, + param2: selectedCurrency, + ); + default: + return getIt(param1: args as ContactRecord?); + } + } +} diff --git a/lib/src/widgets/bottom_sheet/info_steps_bottom_sheet_widget.dart b/lib/src/widgets/bottom_sheet/info_steps_bottom_sheet_widget.dart index 327b2d5430..4352f90745 100644 --- a/lib/src/widgets/bottom_sheet/info_steps_bottom_sheet_widget.dart +++ b/lib/src/widgets/bottom_sheet/info_steps_bottom_sheet_widget.dart @@ -110,5 +110,7 @@ class InfoStepsBottomSheet extends BaseBottomSheet { ); @override - Widget footerWidget(BuildContext context) => SizedBox.shrink(); + Widget footerWidget(BuildContext context) { + return const SizedBox.shrink(); + } } diff --git a/lib/src/widgets/primary_button.dart b/lib/src/widgets/primary_button.dart index b4d8cfdaaf..c92ff0444d 100644 --- a/lib/src/widgets/primary_button.dart +++ b/lib/src/widgets/primary_button.dart @@ -79,6 +79,8 @@ class LoadingPrimaryButton extends StatelessWidget { required this.text, required this.color, required this.textColor, + this.width, + this.height, this.isDisabled = false, this.isLoading = false, super.key, @@ -87,6 +89,8 @@ class LoadingPrimaryButton extends StatelessWidget { final VoidCallback onPressed; final Color color; final Color textColor; + final double? width; + final double? height; final bool isLoading; final bool isDisabled; final String text; @@ -96,8 +100,8 @@ class LoadingPrimaryButton extends StatelessWidget { return ConstrainedBox( constraints: BoxConstraints(maxWidth: ResponsiveLayoutUtilBase.kDesktopMaxWidthConstraint), child: SizedBox( - width: double.infinity, - height: 52.0, + width: width ?? double.infinity, + height: height ?? 52.0, child: TextButton( onPressed: (isLoading || isDisabled) ? null diff --git a/lib/src/widgets/simple_checkbox.dart b/lib/src/widgets/simple_checkbox.dart index 850d9bac1c..0c35743da4 100644 --- a/lib/src/widgets/simple_checkbox.dart +++ b/lib/src/widgets/simple_checkbox.dart @@ -31,4 +31,4 @@ class _SimpleCheckboxState extends State { ), ); } -} \ No newline at end of file +} diff --git a/lib/store/settings_store.dart b/lib/store/settings_store.dart index 4b2b117045..77d0364c6d 100644 --- a/lib/store/settings_store.dart +++ b/lib/store/settings_store.dart @@ -121,6 +121,10 @@ abstract class SettingsStoreBase with Store { required this.lookupsOpenAlias, required this.lookupsENS, required this.lookupsWellKnown, + required this.lookupsFio, + required this.lookupsNostr, + required this.lookupsThorChain, + required this.lookupsBip353, required this.usePayjoin, required this.showPayjoinCard, required this.customBitcoinFeeRate, @@ -505,6 +509,26 @@ abstract class SettingsStoreBase with Store { (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( (_) => usePayjoin, (bool usePayjoin) => @@ -844,6 +868,18 @@ abstract class SettingsStoreBase with Store { @observable bool lookupsWellKnown; + @observable + bool lookupsFio; + + @observable + bool lookupsNostr; + + @observable + bool lookupsThorChain; + + @observable + bool lookupsBip353; + @observable bool usePayjoin; @@ -1055,6 +1091,10 @@ abstract class SettingsStoreBase with Store { final lookupsOpenAlias = sharedPreferences.getBool(PreferencesKey.lookupsOpenAlias) ?? true; final lookupsENS = sharedPreferences.getBool(PreferencesKey.lookupsENS) ?? 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 usePayjoin = sharedPreferences.getBool(PreferencesKey.usePayjoin) ?? false; final showPayjoinCard = sharedPreferences.getBool(PreferencesKey.showPayjoinCard) ?? true; final customBitcoinFeeRate = sharedPreferences.getInt(PreferencesKey.customBitcoinFeeRate) ?? 1; @@ -1370,6 +1410,10 @@ abstract class SettingsStoreBase with Store { lookupsOpenAlias: lookupsOpenAlias, lookupsENS: lookupsENS, lookupsWellKnown: lookupsWellKnown, + lookupsFio: lookupsFio, + lookupsNostr: lookupsNostr, + lookupsThorChain: lookupsThorChain, + lookupsBip353: lookupsBip353, usePayjoin: usePayjoin, showPayjoinCard: showPayjoinCard, customBitcoinFeeRate: customBitcoinFeeRate, diff --git a/lib/twitter/twitter_api.dart b/lib/twitter/twitter_api.dart index cccf769ad0..1bb5de8edc 100644 --- a/lib/twitter/twitter_api.dart +++ b/lib/twitter/twitter_api.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import 'package:cake_wallet/.secrets.g.dart' as secrets; import 'package:cake_wallet/twitter/twitter_user.dart'; +import 'package:cw_core/utils/print_verbose.dart'; import 'package:cw_core/utils/proxy_wrapper.dart'; class TwitterApi { @@ -10,7 +11,9 @@ class TwitterApi { static const apiHost = 'api.twitter.com'; static const userPath = '/2/users/by/username/'; - static Future lookupUserByName({required String userName}) async { + static Future lookupUserByName({required String userName}) async { + + try{ final queryParams = { 'user.fields': 'description,profile_image_url', 'expansions': 'pinned_tweet_id', @@ -41,8 +44,14 @@ class TwitterApi { } return TwitterUser.fromJson(responseJSON, _getPinnedTweet(responseJSON)); + + } catch (e) { + printV('Error in lookupUserByName: $e'); + return null; + } } + static Tweet? _getPinnedTweet(Map responseJSON) { try { final tweetId = responseJSON['data']['pinned_tweet_id'] as String?; @@ -61,6 +70,7 @@ class TwitterApi { return Tweet(id: tweetId, text: pinnedTweetText); } catch (e) { + printV('Error in _getPinnedTweet: $e'); return null; } } diff --git a/lib/utils/address_formatter.dart b/lib/utils/address_formatter.dart index f2083c7724..dc8e99b81c 100644 --- a/lib/utils/address_formatter.dart +++ b/lib/utils/address_formatter.dart @@ -10,8 +10,8 @@ class AddressFormatter { TextStyle? oddTextStyle, TextAlign? textAlign, bool shouldTruncate = false, + int visibleChunks = 2, }) { - final cleanAddress = address.replaceAll('bitcoincash:', ''); final isMWEB = address.startsWith('ltcmweb'); final chunkSize = walletType != null ? _getChunkSize(walletType) : 4; @@ -21,8 +21,10 @@ class AddressFormatter { address: cleanAddress, isMWEB: isMWEB, chunkSize: chunkSize, + visibleChunks: visibleChunks, evenTextStyle: evenTextStyle, - oddTextStyle: oddTextStyle ?? evenTextStyle.copyWith(color: evenTextStyle.color!.withAlpha(150)), + oddTextStyle: + oddTextStyle ?? evenTextStyle.copyWith(color: evenTextStyle.color!.withAlpha(150)), textAlign: textAlign, ); } else { @@ -31,7 +33,8 @@ class AddressFormatter { isMWEB: isMWEB, chunkSize: chunkSize, evenTextStyle: evenTextStyle, - oddTextStyle: oddTextStyle ?? evenTextStyle.copyWith(color: evenTextStyle.color!.withAlpha(128)), + oddTextStyle: + oddTextStyle ?? evenTextStyle.copyWith(color: evenTextStyle.color!.withAlpha(128)), textAlign: textAlign, ); } @@ -45,7 +48,6 @@ class AddressFormatter { required TextStyle oddTextStyle, TextAlign? textAlign, }) { - final chunks = []; if (isMWEB) { @@ -86,40 +88,24 @@ class AddressFormatter { required String address, required bool isMWEB, required int chunkSize, + required int visibleChunks, required TextStyle evenTextStyle, required TextStyle oddTextStyle, TextAlign? textAlign, }) { - if (isMWEB) { - const fixedPrefix = 'ltcmweb'; - final secondChunkStart = fixedPrefix.length; - const chunkSize = 4; - final secondChunk = address.substring( - secondChunkStart, - math.min(secondChunkStart + chunkSize, address.length), - ); - final lastChunk = address.substring(address.length - chunkSize); + const prefix = 'ltcmweb'; + final rest = address.substring(prefix.length); - final spans = [ - TextSpan(text: '$fixedPrefix ', style: evenTextStyle), - TextSpan(text: '$secondChunk ', style: oddTextStyle), - TextSpan(text: '... ', style: oddTextStyle), - TextSpan(text: lastChunk, style: evenTextStyle), - ]; - - return RichText( - text: TextSpan(children: spans), - textAlign: textAlign ?? TextAlign.start, - overflow: TextOverflow.visible, - ); - } else { - final int digitCount = chunkSize; + final chunks = []; + for (int i = 0; i < rest.length; i += chunkSize) { + chunks.add(rest.substring(i, math.min(i + chunkSize, rest.length))); + } - if (address.length <= 2 * digitCount) { + if (chunks.length <= visibleChunks + 1) { return _buildFullSegmentedAddress( address: address, - isMWEB: isMWEB, + isMWEB: true, chunkSize: chunkSize, evenTextStyle: evenTextStyle, oddTextStyle: oddTextStyle, @@ -127,25 +113,60 @@ class AddressFormatter { ); } - final String firstPart = address.substring(0, digitCount); - final String secondPart = - address.substring(digitCount, digitCount * 2); - final String lastPart = - address.substring(address.length - digitCount); - final spans = [ - TextSpan(text: '$firstPart ', style: evenTextStyle), - TextSpan(text: '$secondPart ', style: oddTextStyle), - TextSpan(text: '... ', style: oddTextStyle), - TextSpan(text: lastPart, style: evenTextStyle), + TextSpan(text: '$prefix ', style: evenTextStyle), ]; + for (var i = 0; i < visibleChunks; i++) { + final style = (i.isEven) ? oddTextStyle : evenTextStyle; + spans.add(TextSpan(text: '${chunks[i]} ', style: style)); + } + + spans.add(TextSpan(text: '... ', style: oddTextStyle)); + + final lastStyle = (visibleChunks.isEven) ? evenTextStyle : oddTextStyle; + spans.add(TextSpan(text: chunks.last, style: lastStyle)); + return RichText( text: TextSpan(children: spans), textAlign: textAlign ?? TextAlign.start, overflow: TextOverflow.visible, ); } + + final chunks = []; + for (int i = 0; i < address.length; i += chunkSize) { + chunks.add(address.substring(i, math.min(i + chunkSize, address.length))); + } + + if (chunks.length <= visibleChunks + 1) { + return _buildFullSegmentedAddress( + address: address, + isMWEB: false, + chunkSize: chunkSize, + evenTextStyle: evenTextStyle, + oddTextStyle: oddTextStyle, + textAlign: textAlign, + ); + } + + final spans = []; + + for (var i = 0; i < visibleChunks; i++) { + final style = (i.isEven) ? evenTextStyle : oddTextStyle; + spans.add(TextSpan(text: '${chunks[i]} ', style: style)); + } + + spans.add(TextSpan(text: '... ', style: oddTextStyle)); + + final lastStyle = (visibleChunks.isEven) ? oddTextStyle : evenTextStyle; + spans.add(TextSpan(text: chunks.last, style: lastStyle)); + + return RichText( + text: TextSpan(children: spans), + textAlign: textAlign ?? TextAlign.start, + overflow: TextOverflow.visible, + ); } static int _getChunkSize(WalletType walletType) { @@ -158,4 +179,4 @@ class AddressFormatter { return 4; } } -} \ No newline at end of file +} diff --git a/lib/utils/image_utill.dart b/lib/utils/image_utill.dart index 51525bdfc3..d08a642ca4 100644 --- a/lib/utils/image_utill.dart +++ b/lib/utils/image_utill.dart @@ -1,6 +1,10 @@ +import 'dart:io'; + import 'package:cw_core/utils/proxy_wrapper.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_svg/svg.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; class ImageUtil { static Widget getImageFromPath({ @@ -70,6 +74,40 @@ class ImageUtil { return img; } + static Future saveAvatarLocally(String imageUriOrPath) async { + if (imageUriOrPath.isEmpty) return null; + + try { + final dir = await getApplicationDocumentsDirectory(); + String ext = p.extension(imageUriOrPath); + if (ext.isEmpty) ext = '.png'; + + final file = File('${dir.path}/${DateTime.now().millisecondsSinceEpoch}$ext'); + + if (imageUriOrPath.startsWith('http')) { + final response = await ProxyWrapper() + .get( + clearnetUri: Uri.parse(imageUriOrPath), + ) + .catchError((error) { + throw Exception('HTTP request failed: $error'); + }); + + if (response.statusCode == 200) { + await file.writeAsBytes(response.bodyBytes); + } else { + return null; + } + } else { + await File(imageUriOrPath).copy(file.path); + } + + return file.existsSync() ? file.path : null; + } catch (_) { + return null; + } + } + static Widget _placeholder(double? h, double? w) => (h != null || w != null) ? SizedBox(height: h, width: w, child: const Center(child: CircularProgressIndicator())) : const Center(child: CircularProgressIndicator()); diff --git a/lib/view_model/contact_list/contact_list_view_model.dart b/lib/view_model/contact_list/contact_list_view_model.dart index 730d0735ca..62e9dca520 100644 --- a/lib/view_model/contact_list/contact_list_view_model.dart +++ b/lib/view_model/contact_list/contact_list_view_model.dart @@ -10,6 +10,7 @@ import 'package:cake_wallet/generated/i18n.dart'; import 'package:cake_wallet/store/settings_store.dart'; import 'package:cake_wallet/utils/mobx.dart'; import 'package:cw_core/crypto_currency.dart'; +import 'package:cw_core/wallet_base.dart'; import 'package:cw_core/wallet_info.dart'; import 'package:cw_core/wallet_type.dart'; import 'package:hive/hive.dart'; @@ -105,6 +106,8 @@ abstract class ContactListViewModelBase with Store { bool get isEditable => _currency == null; + CryptoCurrency? get selectedCurrency => _currency; + FilterListOrderType? get orderType => settingsStore.contactListOrder; bool get ascending => settingsStore.contactListAscending; @@ -115,25 +118,43 @@ abstract class ContactListViewModelBase with Store { Future delete(ContactRecord contact) async => contact.original.delete(); - ObservableList get contactsToShow => - ObservableList.of(contacts.where((element) => _isValidForCurrency(element, false))); + ObservableList get contactsToShow => isEditable ? contacts : + ObservableList.of(contacts.where((element) => _isContactValidForCurrency(element))); @computed List get walletContactsToShow => walletContacts.where((element) => _isValidForCurrency(element, true)).toList(); bool _isValidForCurrency(ContactBase element, bool isWalletContact) { - if (_currency == null) return true; + if (isEditable) return true; if (!element.name.contains('Active') && isWalletContact && (element.type == CryptoCurrency.btc || element.type == CryptoCurrency.ltc)) return false; - return element.type == _currency || - (element.type.tag != null && _currency.tag != null && element.type.tag == _currency.tag) || - _currency.toString() == element.type.tag || - _currency.tag == element.type.toString(); + return _isMatchToMainCurrency(element.type); + } + + bool _isContactValidForCurrency(ContactRecord element) { + + final isAnyManualValid = element.original.manualAddresses.keys.any((raw) { + final cur = CryptoCurrency.deserialize(raw: raw); + return _isMatchToMainCurrency(cur); + }); + + if (isAnyManualValid) return true; + + final isAnyParsedValid = element.original.parsedByHandle.values.any((currencyMap) => + currencyMap.keys.any((raw) => _isMatchToMainCurrency(CryptoCurrency.deserialize(raw: raw)))); + + return isAnyParsedValid; } + bool _isMatchToMainCurrency (CryptoCurrency cur) => + _currency!= null && (cur == _currency || + (cur.tag != null && _currency.tag != null && cur.tag == _currency.tag) || + _currency.toString() == cur.tag || + _currency.tag == cur.toString()); + void dispose() => _subscription?.cancel(); void saveCustomOrder() { @@ -141,29 +162,34 @@ abstract class ContactListViewModelBase with Store { reorderContacts(contactsSourceCopy); } + Future saveCustomOrderFrom(Iterable ordered) async { + final orderedContacts = ordered.map((e) => e.original).toList(); + await reorderContacts(orderedContacts); + settingsStore.contactListOrder = FilterListOrderType.Custom; + } + void reorderAccordingToContactList() => settingsStore.contactListOrder = FilterListOrderType.Custom; - Future reorderContacts(List contactCopy) async { - await contactSource.deleteAll(contactCopy.map((e) => e.key).toList()); - await contactSource.addAll(contactCopy); + Future reorderContacts(List ordered) async { + final mapByKey = { for (final c in contactSource.values) c.key: c }; + final keysInNewOrder = ordered.map((c) => c.key).toList(); + await contactSource.deleteAll(contactSource.keys); + await contactSource.addAll(keysInNewOrder.map((k) => mapByKey[k]!)); } - Future sortGroupByType() async { - List contactsSourceCopy = contactSource.values.toList(); - contactsSourceCopy.sort((a, b) => ascending - ? a.type.toString().compareTo(b.type.toString()) - : b.type.toString().compareTo(a.type.toString())); + Future sortAlphabetically() async { + final contactsSourceCopy = contactSource.values.toList(); - await reorderContacts(contactsSourceCopy); - } + contactsSourceCopy.sort((a, b) { + // Trim leading special characters and compare case-insensitively + final keyA = a.name.trim().toLowerCase().replaceFirst(RegExp(r'^[^0-9A-Za-z]+'), ''); + final keyB = b.name.trim().toLowerCase().replaceFirst(RegExp(r'^[^0-9A-Za-z]+'), ''); - Future sortAlphabetically() async { - List contactsSourceCopy = contactSource.values.toList(); + return ascending ? keyA.compareTo(keyB) : keyB.compareTo(keyA); + }); - contactsSourceCopy - .sort((a, b) => ascending ? a.name.compareTo(b.name) : b.name.compareTo(a.name)); await reorderContacts(contactsSourceCopy); } @@ -191,9 +217,6 @@ abstract class ContactListViewModelBase with Store { case FilterListOrderType.Alphabetical: await sortAlphabetically(); break; - case FilterListOrderType.GroupByType: - await sortGroupByType(); - break; case FilterListOrderType.Custom: default: reorderAccordingToContactList(); diff --git a/lib/view_model/contact_list/contact_view_model.dart b/lib/view_model/contact_list/contact_view_model.dart index 2030cf03eb..176c03260e 100644 --- a/lib/view_model/contact_list/contact_view_model.dart +++ b/lib/view_model/contact_list/contact_view_model.dart @@ -1,25 +1,69 @@ -import 'package:cake_wallet/entities/contact_record.dart'; -import 'package:hive/hive.dart'; -import 'package:mobx/mobx.dart'; +import 'dart:io'; + import 'package:cake_wallet/core/execution_state.dart'; -import 'package:cake_wallet/generated/i18n.dart'; +import 'package:cake_wallet/di.dart'; import 'package:cake_wallet/entities/contact.dart'; +import 'package:cake_wallet/entities/contact_record.dart'; +import 'package:cake_wallet/address_resolver/address_resolver_service.dart'; +import 'package:cake_wallet/address_resolver/parsed_address.dart'; +import 'package:cake_wallet/src/screens/address_book/entities/address_edit_request.dart'; +import 'package:cake_wallet/store/settings_store.dart'; import 'package:cw_core/crypto_currency.dart'; +import 'package:cw_core/wallet_base.dart'; +import 'package:flutter/material.dart'; +import 'package:hive/hive.dart'; +import 'package:mobx/mobx.dart'; part 'contact_view_model.g.dart'; -class ContactViewModel = ContactViewModelBase with _$ContactViewModel; +typedef ValidatorType = String? Function(String? raw); + +enum ContactEditMode { + contactInfo, + manualAddress, + parsedAddress, +} -abstract class ContactViewModelBase with Store { - ContactViewModelBase(this._contacts, {ContactRecord? contact}) - : state = InitialExecutionState(), +class ContactViewModel = _ContactViewModel with _$ContactViewModel; + +abstract class _ContactViewModel with Store { + _ContactViewModel( + this.box, + this.wallet, + this.settingsStore, { + AddressEditRequest? request, + }) : mode = request?.mode == EditMode.manualAddressAdd || + request?.mode == EditMode.manualAddressEdit + ? ContactEditMode.manualAddress + : request?.mode == EditMode.parsedAddressAdd || + request?.mode == EditMode.parsedAddressEdit + ? ContactEditMode.parsedAddress + : ContactEditMode.contactInfo, + record = request?.contact, currencies = CryptoCurrency.all, - _contact = contact, - name = contact?.name ?? '', - address = contact?.address ?? '', - currency = contact?.type, - lastChange = contact?.lastChange; + state = InitialExecutionState(), + name = request?.contact?.name ?? '', + handle = request?.contact?.handle ?? '', + profileName = request?.contact?.profileName ?? '', + description = request?.contact?.description ?? '', + imagePath = request?.contact?.imagePath ?? '', + sourceType = request?.contact?.sourceType ?? AddressSource.notParsed, + currency = request?.currency, + label = request?.label ?? '', + handleKey = request?.handleKey ?? '' { + _initMapsFromRecord(); + if (request?.label != null && record != null) { + currency = request!.currency!; + label = request.label!; + } + } + final Box box; + final WalletBase wallet; + final SettingsStore? settingsStore; + final ContactEditMode mode; + final List currencies; + ContactRecord? record; @observable ExecutionState state; @@ -28,63 +72,271 @@ abstract class ContactViewModelBase with Store { String name; @observable - String address; + String handle; + + @observable + String profileName; + + @observable + String description; + + @observable + String imagePath; + + @observable + AddressSource sourceType; @observable CryptoCurrency? currency; - DateTime? lastChange; + @observable + String label; + + @observable + String handleKey; + + @observable + ObservableMap> manual = ObservableMap(); + + @observable + ObservableMap> parsed = ObservableMap(); + + @observable + ObservableMap>> parsedBlocks = ObservableMap(); @computed - bool get isReady => - name.isNotEmpty && - (currency?.toString().isNotEmpty ?? false) && - address.isNotEmpty; + ImageProvider get avatar => imagePath.isEmpty + ? const AssetImage('assets/images/profile.png') + : FileImage(File(imagePath)); - final List currencies; - final Box _contacts; - final ContactRecord? _contact; @action - void reset() { - address = ''; - name = ''; - currency = null; - } + Future refresh() async { + state = IsExecutingState(); + final resolver = getIt(); + + final originalBlocks = Map>>.from(parsedBlocks); - Future save() async { try { - state = IsExecutingState(); - final now = DateTime.now(); + for (final entry in originalBlocks.entries) { + final handleKey = entry.key; + final sourceLabel = handleKey.split('-').first; + final handle = handleKey.substring(sourceLabel.length + 1); - final nameExists = _contact == null - ? doesContactNameExist(name) - : doesContactNameExist(name) && _contact.original.name != name; + final newResults = await resolver.resolve( + query: handle, + wallet: wallet, + ); - if (nameExists) { - state = FailureState(S.current.contact_name_exists); - return; - } + final Map> refreshed = {}; - if (_contact != null && _contact.original.isInBox) { - _contact.name = name; - _contact.address = address; - _contact.type = currency!; - _contact.lastChange = now; - await _contact.save(); - } else { - await _contacts - .add(Contact(name: name, address: address, type: currency!, lastChange: now)); - } + for (final parsed in newResults) { + parsed.parsedAddressByCurrencyMap.forEach((cur, addr) { + final oldLabel = parsedBlocks[handleKey]?[cur]?.keys.firstOrNull ?? cur.title; - lastChange = now; - state = ExecutedSuccessfullyState(); + (refreshed[cur] ??= {})[oldLabel] = addr; + }); + } + + parsedBlocks[handleKey] = refreshed; + record?.replaceParsedBlock(handleKey, refreshed); + } } catch (e) { state = FailureState(e.toString()); + return; + } + + await record?.original.save(); + state = ExecutedSuccessfullyState(); + } + + @action + Future saveContactInfo() async { + if (record != null) { + record! + ..name = name.trim() + ..handle = handle.trim() + ..profileName = profileName.trim() + ..description = description.trim() + ..imagePath = imagePath + ..sourceType = sourceType; + record!.original..lastChange = DateTime.now(); + await record!.original.save(); + state = ExecutedSuccessfullyState(); + return; + } + final newContact = Contact( + name: name.trim(), + address: '', + ) + ..handle = handle.trim() + ..profileName = profileName.trim() + ..description = description.trim() + ..imagePath = imagePath + ..source = sourceType + ..lastChange = DateTime.now(); + + await box.put(newContact.key, newContact); + record = ContactRecord(box, newContact); + } + + @action + Future saveManualAddress({ + required CryptoCurrency oldCurrency, + required CryptoCurrency selectedCurrency, + required String oldLabel, + required String newLabel, + required String newAddress + }) async { + if (record == null) return; + + final oldMap = manual[oldCurrency]; + if (oldMap == null || !oldMap.containsKey(oldLabel)) return; + + final trimmed = newAddress.trim(); + + oldMap.remove(oldLabel); + if (oldMap.isEmpty) { + manual.remove(oldCurrency); + record!.removeManualAddress(oldCurrency, oldLabel); + } else { + manual[oldCurrency] = Map.of(oldMap); + record!.removeManualAddress(oldCurrency, oldLabel); } + + final newMap = manual.putIfAbsent(selectedCurrency, () => {}); + newMap[newLabel] = trimmed; + manual[selectedCurrency] = Map.of(newMap); + record!.setManualAddress(selectedCurrency, newLabel, trimmed); } - bool doesContactNameExist(String name) { - return _contacts.values.any((contact) => contact.name == name); + @action + Future deleteManualAddress( + {required CryptoCurrency currency, required String label}) async { + if (record == null) return; + final map = manual[currency]; + if (map == null || !map.containsKey(label)) return; + + map.remove(label); + if (map.isEmpty) { + manual.remove(currency); + } else { + manual[currency] = Map.of(map); + } + + record!.removeManualAddress(currency, label); } -} \ No newline at end of file + + @action + Future deleteParsedBlock(String handleKey) async { + if (!parsedBlocks.containsKey(handleKey)) return; + + parsedBlocks.remove(handleKey); + record!.removeParsedAddress(handleKey, null, null); + } + + @action + Future deleteContact() async { + if (record == null) return; + + await record!.original.delete(); + record = null; + reset(); + } + + @action + void reset() { + name = handle = profileName = description = imagePath = ''; + label = handleKey = ''; + currency = CryptoCurrency.xmr; + manual.clear(); + parsed.clear(); + parsedBlocks.clear(); + state = InitialExecutionState(); + } + + void _initMapsFromRecord() { + if (record == null) return; + + manual = ObservableMap.of(record!.manual); + parsed = ObservableMap.of(record!.parsedByCurrency); + parsedBlocks = ObservableMap.of(record!.parsedBlocks); + } + + ValidatorType get contactNameValidator => (String? name) { + final value = name?.trim() ?? ''; + + if (value.isEmpty) return 'Name cannot be empty'; + if (value.length > 30) return 'Name is too long'; + + final currentKey = record?.original.key; + final exists = box.values.any( + (c) => c.name.toLowerCase() == value.toLowerCase() && c.key != currentKey, + ); + return exists ? 'Contact with this name already exists' : null; + }; + + ValidatorType get manualAddressLabelValidator => (String? label) { + final value = label?.trim() ?? ''; + + if (value.isEmpty) return 'Label cannot be empty'; + if (value.length > 30) return 'Label is too long'; + if (manual[currency] == null || manual[currency]!.isEmpty) return null; + final exists = + manual[currency]?.keys.any((l) => l.toLowerCase() == value.toLowerCase()) ?? false; + return exists ? 'Label already exists for this currency' : null; + }; + + late final Map lookupMap = settingsStore != null + ? { + AddressSource.twitter.label: ( + () => settingsStore!.lookupsTwitter, + (v) => settingsStore!.lookupsTwitter = v + ), + AddressSource.zanoAlias.label: ( + () => settingsStore!.lookupsZanoAlias, + (v) => settingsStore!.lookupsZanoAlias = v + ), + AddressSource.mastodon.label: ( + () => settingsStore!.lookupsMastodon, + (v) => settingsStore!.lookupsMastodon = v + ), + AddressSource.yatRecord.label: ( + () => settingsStore!.lookupsYatService, + (v) => settingsStore!.lookupsYatService = v + ), + AddressSource.unstoppableDomains.label: ( + () => settingsStore!.lookupsUnstoppableDomains, + (v) => settingsStore!.lookupsUnstoppableDomains = v + ), + AddressSource.openAlias.label: ( + () => settingsStore!.lookupsOpenAlias, + (v) => settingsStore!.lookupsOpenAlias = v + ), + AddressSource.ens.label: ( + () => settingsStore!.lookupsENS, + (v) => settingsStore!.lookupsENS = v + ), + AddressSource.wellKnown.label: ( + () => settingsStore!.lookupsWellKnown, + (v) => settingsStore!.lookupsWellKnown = v + ), + AddressSource.fio.label: ( + () => settingsStore!.lookupsFio, + (v) => settingsStore!.lookupsFio = v + ), + AddressSource.nostr.label: ( + () => settingsStore!.lookupsNostr, + (v) => settingsStore!.lookupsNostr = v + ), + AddressSource.thorChain.label: ( + () => settingsStore!.lookupsThorChain, + (v) => settingsStore!.lookupsThorChain = v + ), + AddressSource.bip353.label: ( + () => settingsStore!.lookupsBip353, + (v) => settingsStore!.lookupsBip353 = v + ), + } + : {}; +} 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 94b1072ab9..853932d0e2 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/seed_validator.dart'; -import 'package:cake_wallet/entities/parse_address_from_domain.dart'; +import 'package:cake_wallet/address_resolver/address_resolver_service.dart'; import 'package:cake_wallet/entities/qr_scanner.dart'; import 'package:cake_wallet/routes.dart'; import 'package:cake_wallet/src/widgets/alert_with_one_action.dart'; @@ -78,7 +78,7 @@ class WalletRestoreFromQRCode { static String? _extractAddressFromUrl(String rawString, WalletType type) { try { - return AddressResolver.extractAddressByType( + return AddressResolverService.extractAddressByType( raw: rawString, type: walletTypeToCryptoCurrency(type), requireSurroundingWhitespaces: false, diff --git a/lib/view_model/send/output.dart b/lib/view_model/send/output.dart index 9a9aa462bf..a0ddb016a7 100644 --- a/lib/view_model/send/output.dart +++ b/lib/view_model/send/output.dart @@ -1,14 +1,21 @@ +import 'package:cake_wallet/address_resolver/address_resolver_service.dart'; +import 'package:cake_wallet/address_resolver/parsed_address.dart'; +import 'package:cake_wallet/bitcoin/bitcoin.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/parse_address_from_domain.dart'; -import 'package:cake_wallet/entities/parsed_address.dart'; import 'package:cake_wallet/ethereum/ethereum.dart'; +import 'package:cake_wallet/generated/i18n.dart'; +import 'package:cake_wallet/monero/monero.dart'; import 'package:cake_wallet/polygon/polygon.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/src/widgets/alert_with_one_action.dart'; +import 'package:cake_wallet/store/dashboard/fiat_conversion_store.dart'; +import 'package:cake_wallet/store/settings_store.dart'; import 'package:cake_wallet/tron/tron.dart'; +import 'package:cake_wallet/utils/show_pop_up.dart'; import 'package:cake_wallet/wownero/wownero.dart'; import 'package:cake_wallet/zano/zano.dart'; import 'package:cw_core/balance.dart'; @@ -16,19 +23,11 @@ import 'package:cw_core/crypto_currency.dart'; import 'package:cw_core/transaction_history.dart'; import 'package:cw_core/transaction_info.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:flutter/material.dart'; import 'package:intl/intl.dart'; import 'package:mobx/mobx.dart'; -import 'package:cw_core/wallet_base.dart'; -import 'package:cake_wallet/monero/monero.dart'; -import 'package:cake_wallet/entities/calculate_fiat_amount.dart'; -import 'package:cw_core/wallet_type.dart'; -import 'package:cake_wallet/store/dashboard/fiat_conversion_store.dart'; -import 'package:cake_wallet/store/settings_store.dart'; -import 'package:cake_wallet/generated/i18n.dart'; -import 'package:cake_wallet/bitcoin/bitcoin.dart'; - -import 'package:cake_wallet/entities/contact_base.dart'; part 'output.g.dart'; @@ -40,7 +39,8 @@ abstract class OutputBase with Store { OutputBase( this._wallet, this._settingsStore, this._fiatConversationStore, this.cryptoCurrencyHandler) : _cryptoNumberFormat = NumberFormat(cryptoNumberPattern), - key = UniqueKey(), + _resolver = getIt(), + key = UniqueKey(), sendAll = false, cryptoAmount = '', cryptoFullBalance = '', @@ -48,10 +48,13 @@ abstract class OutputBase with Store { address = '', note = '', extractedAddress = '', - parsedAddress = ParsedAddress(addresses: []) { + parsedAddress = ParsedAddress(parsedAddressByCurrencyMap: {}) { + _setCryptoNumMaximumFractionDigits(); } + final AddressResolverService _resolver; + Key key; @observable @@ -82,7 +85,7 @@ abstract class OutputBase with Store { @computed bool get isParsedAddress => - parsedAddress.parseFrom != ParseFrom.notParsed && parsedAddress.name.isNotEmpty; + parsedAddress.addressSource != AddressSource.notParsed && parsedAddress.handle != null; @observable String? stealthAddress; @@ -237,6 +240,7 @@ abstract class OutputBase with Store { final SettingsStore _settingsStore; final FiatConversionStore _fiatConversationStore; final NumberFormat _cryptoNumberFormat; + @action void setSendAll(String fullBalance) { cryptoFullBalance = fullBalance; @@ -263,7 +267,7 @@ abstract class OutputBase with Store { void resetParsedAddress() { extractedAddress = ''; - parsedAddress = ParsedAddress(addresses: []); + parsedAddress = ParsedAddress(parsedAddressByCurrencyMap: {}); } @action @@ -313,8 +317,8 @@ 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; @@ -355,16 +359,45 @@ abstract class OutputBase with Store { Future fetchParsedAddress(BuildContext context) async { final domain = address; final currency = cryptoCurrencyHandler(); - parsedAddress = await getIt.get().resolve(context, domain, currency); - extractedAddress = await extractAddressFromParsed(context, parsedAddress); - note = parsedAddress.description; + final parsedAddresses = await _resolver.resolve( + query: domain, + wallet: _wallet, + currency: currency,); + if (parsedAddresses.isNotEmpty) { + parsedAddress = parsedAddresses.first; + final confirmed = await showParsedAddressConfirmationAlert(context, parsedAddress); + extractedAddress = confirmed ? parsedAddress.parsedAddressByCurrencyMap[currency] ?? '' : ''; + note = confirmed ? parsedAddress.description : ''; + } + } + + Future loadContact((String, String) selectedContact) async { + address = selectedContact.$1; + parsedAddress = ParsedAddress( + parsedAddressByCurrencyMap: {}, addressSource: AddressSource.contact); + extractedAddress = selectedContact.$2; + note = parsedAddress.description ?? ''; } - void loadContact(ContactBase contact) { - address = contact.name; - parsedAddress = ParsedAddress.fetchContactAddress(address: contact.address, name: contact.name); - extractedAddress = parsedAddress.addresses.first; - note = parsedAddress.description; + 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/view_model/send/send_view_model.dart b/lib/view_model/send/send_view_model.dart index 841673d16c..63492d2974 100644 --- a/lib/view_model/send/send_view_model.dart +++ b/lib/view_model/send/send_view_model.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:cake_wallet/address_resolver/parsed_address.dart'; import 'package:cake_wallet/bitcoin/bitcoin.dart'; import 'package:cake_wallet/core/address_validator.dart'; import 'package:cake_wallet/core/amount_validator.dart'; @@ -10,11 +11,9 @@ import 'package:cake_wallet/core/validator.dart'; import 'package:cake_wallet/core/wallet_change_listener_view_model.dart'; import 'package:cake_wallet/decred/decred.dart'; import 'package:cake_wallet/entities/calculate_fiat_amount.dart'; -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'; @@ -37,9 +36,7 @@ import 'package:cake_wallet/utils/payment_request.dart'; import 'package:cake_wallet/view_model/contact_list/contact_list_view_model.dart'; import 'package:cake_wallet/view_model/dashboard/balance_view_model.dart'; import 'package:cake_wallet/view_model/hardware_wallet/ledger_view_model.dart'; -import 'package:cake_wallet/view_model/payment/payment_view_model.dart'; import 'package:cake_wallet/view_model/send/fees_view_model.dart'; -import 'package:cake_wallet/view_model/wallet_switcher_view_model.dart'; import 'package:cake_wallet/view_model/send/output.dart'; import 'package:cake_wallet/view_model/send/send_template_view_model.dart'; import 'package:cake_wallet/view_model/send/send_view_model_state.dart'; @@ -311,14 +308,18 @@ 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