From 10fd32fb2e167c57f34bb74c84c908188f631561 Mon Sep 17 00:00:00 2001 From: Serhii Date: Fri, 1 Mar 2024 21:38:48 +0200 Subject: [PATCH] Cw 586 display user twitter image in birdpay (#1315) * Update address_validator.dart * add twitter profile image * mastodon profile image * fix data types --- lib/core/address_validator.dart | 10 +- lib/entities/parse_address_from_domain.dart | 27 +++- lib/entities/parsed_address.dart | 67 +++++--- lib/mastodon/mastodon_user.dart | 5 +- .../widgets/extract_address_from_parsed.dart | 8 + lib/src/widgets/alert_with_one_action.dart | 12 +- lib/src/widgets/base_alert_dialog.dart | 148 ++++++++++++------ lib/twitter/twitter_api.dart | 2 +- lib/twitter/twitter_user.dart | 7 +- 9 files changed, 200 insertions(+), 86 deletions(-) diff --git a/lib/core/address_validator.dart b/lib/core/address_validator.dart index 853762a1c..84fcb9e2e 100644 --- a/lib/core/address_validator.dart +++ b/lib/core/address_validator.dart @@ -270,11 +270,11 @@ class AddressValidator extends TextValidator { '|([^0-9a-zA-Z]|^)8[0-9a-zA-Z]{94}([^0-9a-zA-Z]|\$)' '|([^0-9a-zA-Z]|^)[0-9a-zA-Z]{106}([^0-9a-zA-Z]|\$)'; case CryptoCurrency.btc: - return '([^0-9a-zA-Z]|^)${P2pkhAddress.regex.pattern}|\$)' - '([^0-9a-zA-Z]|^)${P2shAddress.regex.pattern}|\$)' - '([^0-9a-zA-Z]|^)${P2wpkhAddress.regex.pattern}|\$)' - '([^0-9a-zA-Z]|^)${P2wshAddress.regex.pattern}|\$)' - '([^0-9a-zA-Z]|^)${P2trAddress.regex.pattern}|\$)'; + return '([^0-9a-zA-Z]|^)([1mn][a-km-zA-HJ-NP-Z1-9]{25,34})([^0-9a-zA-Z]|\$)' //P2pkhAddress type + '|([^0-9a-zA-Z]|^)([23][a-km-zA-HJ-NP-Z1-9]{25,34})([^0-9a-zA-Z]|\$)' //P2shAddress type + '|([^0-9a-zA-Z]|^)((bc|tb)1q[ac-hj-np-z02-9]{25,39})([^0-9a-zA-Z]|\$)' //P2wpkhAddress type + '|([^0-9a-zA-Z]|^)((bc|tb)1q[ac-hj-np-z02-9]{40,80})([^0-9a-zA-Z]|\$)' //P2wshAddress type + '|([^0-9a-zA-Z]|^)((bc|tb)1p([ac-hj-np-z02-9]{39}|[ac-hj-np-z02-9]{59}|[ac-hj-np-z02-9]{8,89}))([^0-9a-zA-Z]|\$)'; //P2trAddress type case CryptoCurrency.ltc: return '([^0-9a-zA-Z]|^)^L[a-zA-Z0-9]{26,33}([^0-9a-zA-Z]|\$)' '|([^0-9a-zA-Z]|^)[LM][a-km-zA-HJ-NP-Z1-9]{26,33}([^0-9a-zA-Z]|\$)' diff --git a/lib/entities/parse_address_from_domain.dart b/lib/entities/parse_address_from_domain.dart index 52bcc495b..3ebc08c55 100644 --- a/lib/entities/parse_address_from_domain.dart +++ b/lib/entities/parse_address_from_domain.dart @@ -69,16 +69,20 @@ class AddressResolver { } - Future resolve(BuildContext context, String text, String ticker) async { + Future resolve(BuildContext context, String text, String ticker) async { try { if (text.startsWith('@') && !text.substring(1).contains('@')) { - if(settingsStore.lookupsTwitter) { + 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)); if (addressFromBio != null) { - return ParsedAddress.fetchTwitterAddress(address: addressFromBio, name: text); + return ParsedAddress.fetchTwitterAddress( + address: addressFromBio, + name: text, + profileImageUrl: twitterUser.profileImageUrl, + profileName: twitterUser.name); } final pinnedTweet = twitterUser.pinnedTweet?.text; @@ -86,7 +90,11 @@ class AddressResolver { final addressFromPinnedTweet = extractAddressByType(raw: pinnedTweet, type: CryptoCurrency.fromString(ticker)); if (addressFromPinnedTweet != null) { - return ParsedAddress.fetchTwitterAddress(address: addressFromPinnedTweet, name: text); + return ParsedAddress.fetchTwitterAddress( + address: addressFromPinnedTweet, + name: text, + profileImageUrl: twitterUser.profileImageUrl, + profileName: twitterUser.name); } } } @@ -107,7 +115,11 @@ class AddressResolver { extractAddressByType(raw: mastodonUser.note, type: CryptoCurrency.fromString(ticker)); if (addressFromBio != null) { - return ParsedAddress.fetchMastodonAddress(address: addressFromBio, name: text); + return ParsedAddress.fetchMastodonAddress( + address: addressFromBio, + name: text, + profileImageUrl: mastodonUser.profileImageUrl, + profileName: mastodonUser.username); } else { final pinnedPosts = await MastodonAPI.getPinnedPosts(userId: mastodonUser.id, apiHost: hostName); @@ -119,7 +131,10 @@ class AddressResolver { if (addressFromPinnedPost != null) { return ParsedAddress.fetchMastodonAddress( - address: addressFromPinnedPost, name: text); + address: addressFromPinnedPost, + name: text, + profileImageUrl: mastodonUser.profileImageUrl, + profileName: mastodonUser.username); } } } diff --git a/lib/entities/parsed_address.dart b/lib/entities/parsed_address.dart index d414a827d..d87deb9e8 100644 --- a/lib/entities/parsed_address.dart +++ b/lib/entities/parsed_address.dart @@ -1,7 +1,6 @@ import 'package:cake_wallet/entities/openalias_record.dart'; import 'package:cake_wallet/entities/yat_record.dart'; - enum ParseFrom { unstoppableDomains, openAlias, @@ -20,36 +19,37 @@ class ParsedAddress { required this.addresses, this.name = '', this.description = '', + this.profileImageUrl = '', + this.profileName = '', this.parseFrom = ParseFrom.notParsed, }); 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, - ); + }) { + 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, - ); + }) { + if (address?.isEmpty ?? true) { + return ParsedAddress(addresses: [name]); + } + return ParsedAddress( + addresses: [address!], + name: name, + parseFrom: ParseFrom.unstoppableDomains, + ); } factory ParsedAddress.fetchOpenAliasAddress( @@ -65,7 +65,7 @@ class ParsedAddress { ); } - factory ParsedAddress.fetchFioAddress({required String address, required String name}){ + factory ParsedAddress.fetchFioAddress({required String address, required String name}) { return ParsedAddress( addresses: [address], name: name, @@ -73,23 +73,37 @@ class ParsedAddress { ); } - factory ParsedAddress.fetchTwitterAddress({required String address, required String name}){ + 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}){ + factory ParsedAddress.fetchMastodonAddress( + {required String address, + required String name, + required String profileImageUrl, + required String profileName}) { return ParsedAddress( addresses: [address], name: name, - parseFrom: ParseFrom.mastodon + parseFrom: ParseFrom.mastodon, + profileImageUrl: profileImageUrl, + profileName: profileName, ); } - factory ParsedAddress.fetchContactAddress({required String address, required String name}){ + factory ParsedAddress.fetchContactAddress({required String address, required String name}) { return ParsedAddress( addresses: [address], name: name, @@ -116,6 +130,7 @@ class ParsedAddress { final List addresses; final String name; final String description; + final String profileImageUrl; + final String profileName; final ParseFrom parseFrom; - } diff --git a/lib/mastodon/mastodon_user.dart b/lib/mastodon/mastodon_user.dart index f5a29f298..1832c083e 100644 --- a/lib/mastodon/mastodon_user.dart +++ b/lib/mastodon/mastodon_user.dart @@ -1,12 +1,14 @@ class MastodonUser { String id; String username; + String profileImageUrl; String acct; String note; MastodonUser({ required this.id, required this.username, + required this.profileImageUrl, required this.acct, required this.note, }); @@ -14,9 +16,10 @@ class MastodonUser { factory MastodonUser.fromJson(Map json) { return MastodonUser( id: json['id'] as String, - username: json['username'] as String, + username: json['username'] as String? ?? '', acct: json['acct'] as String, note: json['note'] as String, + profileImageUrl: json['avatar'] as String? ?? '' ); } } diff --git a/lib/src/screens/send/widgets/extract_address_from_parsed.dart b/lib/src/screens/send/widgets/extract_address_from_parsed.dart index bb09d4ca3..42e646d58 100644 --- a/lib/src/screens/send/widgets/extract_address_from_parsed.dart +++ b/lib/src/screens/send/widgets/extract_address_from_parsed.dart @@ -11,6 +11,8 @@ Future extractAddressFromParsed( var title = ''; var content = ''; var address = ''; + var profileImageUrl = ''; + var profileName = ''; switch (parsedAddress.parseFrom) { case ParseFrom.unstoppableDomains: @@ -37,11 +39,15 @@ Future extractAddressFromParsed( 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; @@ -95,6 +101,8 @@ Future extractAddressFromParsed( return 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()); diff --git a/lib/src/widgets/alert_with_one_action.dart b/lib/src/widgets/alert_with_one_action.dart index c06114f5b..7ad0ac1af 100644 --- a/lib/src/widgets/alert_with_one_action.dart +++ b/lib/src/widgets/alert_with_one_action.dart @@ -7,7 +7,9 @@ class AlertWithOneAction extends BaseAlertDialog { required this.alertContent, required this.buttonText, required this.buttonAction, - this.alertBarrierDismissible = true + this.alertBarrierDismissible = true, + this.headerTitleText, + this.headerImageProfileUrl }); final String alertTitle; @@ -15,6 +17,8 @@ class AlertWithOneAction extends BaseAlertDialog { final String buttonText; final VoidCallback buttonAction; final bool alertBarrierDismissible; + final String? headerTitleText; + final String? headerImageProfileUrl; @override String get titleText => alertTitle; @@ -25,6 +29,12 @@ class AlertWithOneAction extends BaseAlertDialog { @override bool get barrierDismissible => alertBarrierDismissible; + @override + String? get headerImageUrl => headerImageProfileUrl; + + @override + String? get headerText => headerTitleText; + @override Widget actionButtons(BuildContext context) { return Container( diff --git a/lib/src/widgets/base_alert_dialog.dart b/lib/src/widgets/base_alert_dialog.dart index 02a1f6ad0..e9ef522df 100644 --- a/lib/src/widgets/base_alert_dialog.dart +++ b/lib/src/widgets/base_alert_dialog.dart @@ -1,3 +1,4 @@ +import 'package:cake_wallet/src/screens/receive/widgets/header_tile.dart'; import 'package:cake_wallet/themes/extensions/cake_text_theme.dart'; import 'dart:ui'; import 'package:cake_wallet/src/widgets/section_divider.dart'; @@ -5,19 +6,34 @@ import 'package:cake_wallet/themes/extensions/alert_theme.dart'; import 'package:flutter/material.dart'; class BaseAlertDialog extends StatelessWidget { + String? get headerText => ''; + String get titleText => ''; + String get contentText => ''; + String get leftActionButtonText => ''; + String get rightActionButtonText => ''; + bool get isDividerExists => false; + VoidCallback get actionLeft => () {}; + VoidCallback get actionRight => () {}; + bool get barrierDismissible => true; + Color? get leftActionButtonTextColor => null; + Color? get rightActionButtonTextColor => null; + Color? get leftActionButtonColor => null; + Color? get rightActionButtonColor => null; + String? get headerImageUrl => null; + Widget title(BuildContext context) { return Text( titleText, @@ -32,6 +48,23 @@ class BaseAlertDialog extends StatelessWidget { ); } + Widget headerTitle(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(top: 10.0), + child: Text( + headerText!, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 25, + fontFamily: 'Lato', + fontWeight: FontWeight.w600, + color: Theme.of(context).extension()!.titleColor, + decoration: TextDecoration.none, + ), + ), + ); + } + Widget content(BuildContext context) { return Text( contentText, @@ -48,17 +81,17 @@ class BaseAlertDialog extends StatelessWidget { Widget actionButtons(BuildContext context) { return Container( - height: 60, - child: Row( - mainAxisSize: MainAxisSize.max, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ + height: 60, + child: Row( + mainAxisSize: MainAxisSize.max, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ Expanded( child: TextButton( onPressed: actionLeft, style: TextButton.styleFrom( - backgroundColor: leftActionButtonColor ?? - Theme.of(context).dialogBackgroundColor, + backgroundColor: + leftActionButtonColor ?? Theme.of(context).dialogBackgroundColor, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.all(Radius.zero))), child: Text( @@ -79,8 +112,8 @@ class BaseAlertDialog extends StatelessWidget { child: TextButton( onPressed: actionRight, style: TextButton.styleFrom( - backgroundColor: rightActionButtonColor ?? - Theme.of(context).dialogBackgroundColor, + backgroundColor: + rightActionButtonColor ?? Theme.of(context).dialogBackgroundColor, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.all(Radius.zero))), child: Text( @@ -90,8 +123,7 @@ class BaseAlertDialog extends StatelessWidget { fontSize: 15, fontFamily: 'Lato', fontWeight: FontWeight.w600, - color: rightActionButtonTextColor ?? - Theme.of(context).primaryColor, + color: rightActionButtonTextColor ?? Theme.of(context).primaryColor, decoration: TextDecoration.none, ), )), @@ -100,6 +132,24 @@ class BaseAlertDialog extends StatelessWidget { )); } + Widget headerImage(BuildContext context, String imageUrl) { + return Positioned( + top: -50, + left: 0, + right: 0, + child: CircleAvatar( + radius: 50, + backgroundColor: Colors.white, + child: ClipOval( + child: Image.network( + imageUrl, + fit: BoxFit.cover, + ), + ), + ), + ); + } + @override Widget build(BuildContext context) { return GestureDetector( @@ -109,43 +159,51 @@ class BaseAlertDialog extends StatelessWidget { child: BackdropFilter( filter: ImageFilter.blur(sigmaX: 3.0, sigmaY: 3.0), child: Container( - decoration: BoxDecoration( - color: - Theme.of(context).extension()!.backdropColor), + decoration: + BoxDecoration(color: Theme.of(context).extension()!.backdropColor), child: Center( child: GestureDetector( onTap: () => null, - child: ClipRRect( - borderRadius: BorderRadius.all(Radius.circular(30)), - child: Container( - width: 300, - color: Theme.of(context).dialogBackgroundColor, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Column( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Padding( - padding: EdgeInsets.fromLTRB(24, 20, 24, 0), - child: title(context), - ), - isDividerExists - ? Padding( - padding: EdgeInsets.only(top: 16, bottom: 8), - child: const HorizontalSectionDivider(), - ) - : Offstage(), - Padding( - padding: EdgeInsets.fromLTRB(24, 8, 24, 32), - child: content(context), - ) - ], - ), - const HorizontalSectionDivider(), - actionButtons(context) - ], - ), + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(30), + color: Theme.of(context).dialogBackgroundColor), + width: 300, + child: Stack( + clipBehavior: Clip.none, + children: [ + if (headerImageUrl != null) headerImage(context, headerImageUrl!), + Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (headerImageUrl != null) const SizedBox(height: 50), + Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + if (headerText != null) headerTitle(context), + Padding( + padding: EdgeInsets.fromLTRB(24, 20, 24, 0), + child: title(context), + ), + isDividerExists + ? Padding( + padding: EdgeInsets.only(top: 16, bottom: 8), + child: const HorizontalSectionDivider(), + ) + : Offstage(), + Padding( + padding: EdgeInsets.fromLTRB(24, 8, 24, 32), + child: content(context), + ) + ], + ), + const HorizontalSectionDivider(), + ClipRRect( + borderRadius: BorderRadius.all(Radius.circular(30)), + child: actionButtons(context)) + ], + ), + ], ), ), ), diff --git a/lib/twitter/twitter_api.dart b/lib/twitter/twitter_api.dart index 24121c9c0..bf6298dae 100644 --- a/lib/twitter/twitter_api.dart +++ b/lib/twitter/twitter_api.dart @@ -12,7 +12,7 @@ class TwitterApi { static Future lookupUserByName({required String userName}) async { final queryParams = { - 'user.fields': 'description', + 'user.fields': 'description,profile_image_url', 'expansions': 'pinned_tweet_id', 'tweet.fields': 'note_tweet' }; diff --git a/lib/twitter/twitter_user.dart b/lib/twitter/twitter_user.dart index c0eb5431c..01db25684 100644 --- a/lib/twitter/twitter_user.dart +++ b/lib/twitter/twitter_user.dart @@ -4,20 +4,25 @@ class TwitterUser { required this.username, required this.name, required this.description, + required this.profileImageUrl, this.pinnedTweet}); final String id; final String username; final String name; final String description; + final String profileImageUrl; final Tweet? pinnedTweet; factory TwitterUser.fromJson(Map json, [Tweet? pinnedTweet]) { + final profileImageUrl = json['data']['profile_image_url'] as String? ?? ''; + final scaledProfileImageUrl = profileImageUrl.replaceFirst('normal', '200x200'); return TwitterUser( id: json['data']['id'] as String, - username: json['data']['username'] as String, + username: json['data']['username'] as String? ?? '', name: json['data']['name'] as String, description: json['data']['description'] as String? ?? '', + profileImageUrl: scaledProfileImageUrl, pinnedTweet: pinnedTweet, ); }