Cw 586 display user twitter image in birdpay (#1315)

* Update address_validator.dart * add twitter profile image * mastodon profile image * fix data types

Serhii committed Mar 1, 2024 at 21:38 UTC 10fd32fb2e167c57f34bb74c84c908188f631561
9 files changed +200 -86
lib/core/address_validator.dart
+5 -5
@@ -270,11 +270,11 @@ class AddressValidator extends TextValidator {
270 '|([^0-9a-zA-Z]|^)8[0-9a-zA-Z]{94}([^0-9a-zA-Z]|\$)'
271 '|([^0-9a-zA-Z]|^)[0-9a-zA-Z]{106}([^0-9a-zA-Z]|\$)';
272 case CryptoCurrency.btc:
273 - return '([^0-9a-zA-Z]|^)${P2pkhAddress.regex.pattern}|\$)'
274 - '([^0-9a-zA-Z]|^)${P2shAddress.regex.pattern}|\$)'
275 - '([^0-9a-zA-Z]|^)${P2wpkhAddress.regex.pattern}|\$)'
276 - '([^0-9a-zA-Z]|^)${P2wshAddress.regex.pattern}|\$)'
277 - '([^0-9a-zA-Z]|^)${P2trAddress.regex.pattern}|\$)';
273 + return '([^0-9a-zA-Z]|^)([1mn][a-km-zA-HJ-NP-Z1-9]{25,34})([^0-9a-zA-Z]|\$)' //P2pkhAddress type
274 + '|([^0-9a-zA-Z]|^)([23][a-km-zA-HJ-NP-Z1-9]{25,34})([^0-9a-zA-Z]|\$)' //P2shAddress type
275 + '|([^0-9a-zA-Z]|^)((bc|tb)1q[ac-hj-np-z02-9]{25,39})([^0-9a-zA-Z]|\$)' //P2wpkhAddress type
276 + '|([^0-9a-zA-Z]|^)((bc|tb)1q[ac-hj-np-z02-9]{40,80})([^0-9a-zA-Z]|\$)' //P2wshAddress type
277 + '|([^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
278 case CryptoCurrency.ltc:
279 return '([^0-9a-zA-Z]|^)^L[a-zA-Z0-9]{26,33}([^0-9a-zA-Z]|\$)'
280 '|([^0-9a-zA-Z]|^)[LM][a-km-zA-HJ-NP-Z1-9]{26,33}([^0-9a-zA-Z]|\$)'
lib/entities/parse_address_from_domain.dart
+21 -6
@@ -69,16 +69,20 @@ class AddressResolver {
69 }
70
71
72 - Future<ParsedAddress> resolve(BuildContext context, String text, String ticker) async {
72 + Future<ParsedAddress> resolve(BuildContext context, String text, String ticker) async {
73 try {
74 if (text.startsWith('@') && !text.substring(1).contains('@')) {
75 - if(settingsStore.lookupsTwitter) {
75 + if (settingsStore.lookupsTwitter) {
76 final formattedName = text.substring(1);
77 final twitterUser = await TwitterApi.lookupUserByName(userName: formattedName);
78 final addressFromBio = extractAddressByType(
79 raw: twitterUser.description, type: CryptoCurrency.fromString(ticker));
80 if (addressFromBio != null) {
81 - return ParsedAddress.fetchTwitterAddress(address: addressFromBio, name: text);
81 + return ParsedAddress.fetchTwitterAddress(
82 + address: addressFromBio,
83 + name: text,
84 + profileImageUrl: twitterUser.profileImageUrl,
85 + profileName: twitterUser.name);
86 }
87
88 final pinnedTweet = twitterUser.pinnedTweet?.text;
@@ -86,7 +90,11 @@ class AddressResolver {
90 final addressFromPinnedTweet =
91 extractAddressByType(raw: pinnedTweet, type: CryptoCurrency.fromString(ticker));
92 if (addressFromPinnedTweet != null) {
89 - return ParsedAddress.fetchTwitterAddress(address: addressFromPinnedTweet, name: text);
93 + return ParsedAddress.fetchTwitterAddress(
94 + address: addressFromPinnedTweet,
95 + name: text,
96 + profileImageUrl: twitterUser.profileImageUrl,
97 + profileName: twitterUser.name);
98 }
99 }
100 }
@@ -107,7 +115,11 @@ class AddressResolver {
115 extractAddressByType(raw: mastodonUser.note, type: CryptoCurrency.fromString(ticker));
116
117 if (addressFromBio != null) {
110 - return ParsedAddress.fetchMastodonAddress(address: addressFromBio, name: text);
118 + return ParsedAddress.fetchMastodonAddress(
119 + address: addressFromBio,
120 + name: text,
121 + profileImageUrl: mastodonUser.profileImageUrl,
122 + profileName: mastodonUser.username);
123 } else {
124 final pinnedPosts =
125 await MastodonAPI.getPinnedPosts(userId: mastodonUser.id, apiHost: hostName);
@@ -119,7 +131,10 @@ class AddressResolver {
131
132 if (addressFromPinnedPost != null) {
133 return ParsedAddress.fetchMastodonAddress(
122 - address: addressFromPinnedPost, name: text);
134 + address: addressFromPinnedPost,
135 + name: text,
136 + profileImageUrl: mastodonUser.profileImageUrl,
137 + profileName: mastodonUser.username);
138 }
139 }
140 }
lib/entities/parsed_address.dart
+41 -26
@@ -1,7 +1,6 @@
1 import 'package:cake_wallet/entities/openalias_record.dart';
2 import 'package:cake_wallet/entities/yat_record.dart';
3
4 -
4 enum ParseFrom {
5 unstoppableDomains,
6 openAlias,
@@ -20,36 +19,37 @@ class ParsedAddress {
19 required this.addresses,
20 this.name = '',
21 this.description = '',
22 + this.profileImageUrl = '',
23 + this.profileName = '',
24 this.parseFrom = ParseFrom.notParsed,
25 });
26
27 factory ParsedAddress.fetchEmojiAddress({
28 List<YatRecord>? addresses,
29 required String name,
29 - }){
30 - if (addresses?.isEmpty ?? true) {
31 - return ParsedAddress(
32 - addresses: [name], parseFrom: ParseFrom.yatRecord);
33 - }
34 - return ParsedAddress(
35 - addresses: addresses!.map((e) => e.address).toList(),
36 - name: name,
37 - parseFrom: ParseFrom.yatRecord,
38 - );
30 + }) {
31 + if (addresses?.isEmpty ?? true) {
32 + return ParsedAddress(addresses: [name], parseFrom: ParseFrom.yatRecord);
33 + }
34 + return ParsedAddress(
35 + addresses: addresses!.map((e) => e.address).toList(),
36 + name: name,
37 + parseFrom: ParseFrom.yatRecord,
38 + );
39 }
40
41 factory ParsedAddress.fetchUnstoppableDomainAddress({
42 String? address,
43 required String name,
44 - }){
45 - if (address?.isEmpty ?? true) {
46 - return ParsedAddress(addresses: [name]);
47 - }
48 - return ParsedAddress(
49 - addresses: [address!],
50 - name: name,
51 - parseFrom: ParseFrom.unstoppableDomains,
52 - );
44 + }) {
45 + if (address?.isEmpty ?? true) {
46 + return ParsedAddress(addresses: [name]);
47 + }
48 + return ParsedAddress(
49 + addresses: [address!],
50 + name: name,
51 + parseFrom: ParseFrom.unstoppableDomains,
52 + );
53 }
54
55 factory ParsedAddress.fetchOpenAliasAddress(
@@ -65,7 +65,7 @@ class ParsedAddress {
65 );
66 }
67
68 - factory ParsedAddress.fetchFioAddress({required String address, required String name}){
68 + factory ParsedAddress.fetchFioAddress({required String address, required String name}) {
69 return ParsedAddress(
70 addresses: [address],
71 name: name,
@@ -73,23 +73,37 @@ class ParsedAddress {
73 );
74 }
75
76 - factory ParsedAddress.fetchTwitterAddress({required String address, required String name}){
76 + factory ParsedAddress.fetchTwitterAddress(
77 + {required String address,
78 + required String name,
79 + required String profileImageUrl,
80 + required String profileName,
81 + String? description}) {
82 return ParsedAddress(
83 addresses: [address],
84 name: name,
85 + description: description ?? '',
86 + profileImageUrl: profileImageUrl,
87 + profileName: profileName,
88 parseFrom: ParseFrom.twitter,
89 );
90 }
91
84 - factory ParsedAddress.fetchMastodonAddress({required String address, required String name}){
92 + factory ParsedAddress.fetchMastodonAddress(
93 + {required String address,
94 + required String name,
95 + required String profileImageUrl,
96 + required String profileName}) {
97 return ParsedAddress(
98 addresses: [address],
99 name: name,
88 - parseFrom: ParseFrom.mastodon
100 + parseFrom: ParseFrom.mastodon,
101 + profileImageUrl: profileImageUrl,
102 + profileName: profileName,
103 );
104 }
105
92 - factory ParsedAddress.fetchContactAddress({required String address, required String name}){
106 + factory ParsedAddress.fetchContactAddress({required String address, required String name}) {
107 return ParsedAddress(
108 addresses: [address],
109 name: name,
@@ -116,6 +130,7 @@ class ParsedAddress {
130 final List<String> addresses;
131 final String name;
132 final String description;
133 + final String profileImageUrl;
134 + final String profileName;
135 final ParseFrom parseFrom;
120 -
136 }
lib/mastodon/mastodon_user.dart
+4 -1
@@ -1,12 +1,14 @@
1 class MastodonUser {
2 String id;
3 String username;
4 + String profileImageUrl;
5 String acct;
6 String note;
7
8 MastodonUser({
9 required this.id,
10 required this.username,
11 + required this.profileImageUrl,
12 required this.acct,
13 required this.note,
14 });
@@ -14,9 +16,10 @@ class MastodonUser {
16 factory MastodonUser.fromJson(Map<String, dynamic> json) {
17 return MastodonUser(
18 id: json['id'] as String,
17 - username: json['username'] as String,
19 + username: json['username'] as String? ?? '',
20 acct: json['acct'] as String,
21 note: json['note'] as String,
22 + profileImageUrl: json['avatar'] as String? ?? ''
23 );
24 }
25 }
lib/src/screens/send/widgets/extract_address_from_parsed.dart
+8
@@ -11,6 +11,8 @@ Future<String> extractAddressFromParsed(
11 var title = '';
12 var content = '';
13 var address = '';
14 + var profileImageUrl = '';
15 + var profileName = '';
16
17 switch (parsedAddress.parseFrom) {
18 case ParseFrom.unstoppableDomains:
@@ -37,11 +39,15 @@ Future<String> extractAddressFromParsed(
39 title = S.of(context).address_detected;
40 content = S.of(context).extracted_address_content('${parsedAddress.name} (Twitter)');
41 address = parsedAddress.addresses.first;
42 + profileImageUrl = parsedAddress.profileImageUrl;
43 + profileName = parsedAddress.profileName;
44 break;
45 case ParseFrom.mastodon:
46 title = S.of(context).address_detected;
47 content = S.of(context).extracted_address_content('${parsedAddress.name} (Mastodon)');
48 address = parsedAddress.addresses.first;
49 + profileImageUrl = parsedAddress.profileImageUrl;
50 + profileName = parsedAddress.profileName;
51 break;
52 case ParseFrom.nostr:
53 title = S.of(context).address_detected;
@@ -95,6 +101,8 @@ Future<String> extractAddressFromParsed(
101
102 return AlertWithOneAction(
103 alertTitle: title,
104 + headerTitleText: profileName.isEmpty ? null : profileName,
105 + headerImageProfileUrl: profileImageUrl.isEmpty ? null : profileImageUrl,
106 alertContent: content,
107 buttonText: S.of(context).ok,
108 buttonAction: () => Navigator.of(context).pop());
lib/src/widgets/alert_with_one_action.dart
+11 -1
@@ -7,7 +7,9 @@ class AlertWithOneAction extends BaseAlertDialog {
7 required this.alertContent,
8 required this.buttonText,
9 required this.buttonAction,
10 - this.alertBarrierDismissible = true
10 + this.alertBarrierDismissible = true,
11 + this.headerTitleText,
12 + this.headerImageProfileUrl
13 });
14
15 final String alertTitle;
@@ -15,6 +17,8 @@ class AlertWithOneAction extends BaseAlertDialog {
17 final String buttonText;
18 final VoidCallback buttonAction;
19 final bool alertBarrierDismissible;
20 + final String? headerTitleText;
21 + final String? headerImageProfileUrl;
22
23 @override
24 String get titleText => alertTitle;
@@ -25,6 +29,12 @@ class AlertWithOneAction extends BaseAlertDialog {
29 @override
30 bool get barrierDismissible => alertBarrierDismissible;
31
32 + @override
33 + String? get headerImageUrl => headerImageProfileUrl;
34 +
35 + @override
36 + String? get headerText => headerTitleText;
37 +
38 @override
39 Widget actionButtons(BuildContext context) {
40 return Container(
lib/src/widgets/base_alert_dialog.dart
+103 -45
@@ -1,3 +1,4 @@
1 +import 'package:cake_wallet/src/screens/receive/widgets/header_tile.dart';
2 import 'package:cake_wallet/themes/extensions/cake_text_theme.dart';
3 import 'dart:ui';
4 import 'package:cake_wallet/src/widgets/section_divider.dart';
@@ -5,19 +6,34 @@ import 'package:cake_wallet/themes/extensions/alert_theme.dart';
6 import 'package:flutter/material.dart';
7
8 class BaseAlertDialog extends StatelessWidget {
9 + String? get headerText => '';
10 +
11 String get titleText => '';
12 +
13 String get contentText => '';
14 +
15 String get leftActionButtonText => '';
16 +
17 String get rightActionButtonText => '';
18 +
19 bool get isDividerExists => false;
20 +
21 VoidCallback get actionLeft => () {};
22 +
23 VoidCallback get actionRight => () {};
24 +
25 bool get barrierDismissible => true;
26 +
27 Color? get leftActionButtonTextColor => null;
28 +
29 Color? get rightActionButtonTextColor => null;
30 +
31 Color? get leftActionButtonColor => null;
32 +
33 Color? get rightActionButtonColor => null;
34
35 + String? get headerImageUrl => null;
36 +
37 Widget title(BuildContext context) {
38 return Text(
39 titleText,
@@ -32,6 +48,23 @@ class BaseAlertDialog extends StatelessWidget {
48 );
49 }
50
51 + Widget headerTitle(BuildContext context) {
52 + return Padding(
53 + padding: const EdgeInsets.only(top: 10.0),
54 + child: Text(
55 + headerText!,
56 + textAlign: TextAlign.center,
57 + style: TextStyle(
58 + fontSize: 25,
59 + fontFamily: 'Lato',
60 + fontWeight: FontWeight.w600,
61 + color: Theme.of(context).extension<CakeTextTheme>()!.titleColor,
62 + decoration: TextDecoration.none,
63 + ),
64 + ),
65 + );
66 + }
67 +
68 Widget content(BuildContext context) {
69 return Text(
70 contentText,
@@ -48,17 +81,17 @@ class BaseAlertDialog extends StatelessWidget {
81
82 Widget actionButtons(BuildContext context) {
83 return Container(
51 - height: 60,
52 - child: Row(
53 - mainAxisSize: MainAxisSize.max,
54 - crossAxisAlignment: CrossAxisAlignment.stretch,
55 - children: <Widget>[
84 + height: 60,
85 + child: Row(
86 + mainAxisSize: MainAxisSize.max,
87 + crossAxisAlignment: CrossAxisAlignment.stretch,
88 + children: <Widget>[
89 Expanded(
90 child: TextButton(
91 onPressed: actionLeft,
92 style: TextButton.styleFrom(
60 - backgroundColor: leftActionButtonColor ??
61 - Theme.of(context).dialogBackgroundColor,
93 + backgroundColor:
94 + leftActionButtonColor ?? Theme.of(context).dialogBackgroundColor,
95 shape: const RoundedRectangleBorder(
96 borderRadius: BorderRadius.all(Radius.zero))),
97 child: Text(
@@ -79,8 +112,8 @@ class BaseAlertDialog extends StatelessWidget {
112 child: TextButton(
113 onPressed: actionRight,
114 style: TextButton.styleFrom(
82 - backgroundColor: rightActionButtonColor ??
83 - Theme.of(context).dialogBackgroundColor,
115 + backgroundColor:
116 + rightActionButtonColor ?? Theme.of(context).dialogBackgroundColor,
117 shape: const RoundedRectangleBorder(
118 borderRadius: BorderRadius.all(Radius.zero))),
119 child: Text(
@@ -90,8 +123,7 @@ class BaseAlertDialog extends StatelessWidget {
123 fontSize: 15,
124 fontFamily: 'Lato',
125 fontWeight: FontWeight.w600,
93 - color: rightActionButtonTextColor ??
94 - Theme.of(context).primaryColor,
126 + color: rightActionButtonTextColor ?? Theme.of(context).primaryColor,
127 decoration: TextDecoration.none,
128 ),
129 )),
@@ -100,6 +132,24 @@ class BaseAlertDialog extends StatelessWidget {
132 ));
133 }
134
135 + Widget headerImage(BuildContext context, String imageUrl) {
136 + return Positioned(
137 + top: -50,
138 + left: 0,
139 + right: 0,
140 + child: CircleAvatar(
141 + radius: 50,
142 + backgroundColor: Colors.white,
143 + child: ClipOval(
144 + child: Image.network(
145 + imageUrl,
146 + fit: BoxFit.cover,
147 + ),
148 + ),
149 + ),
150 + );
151 + }
152 +
153 @override
154 Widget build(BuildContext context) {
155 return GestureDetector(
@@ -109,43 +159,51 @@ class BaseAlertDialog extends StatelessWidget {
159 child: BackdropFilter(
160 filter: ImageFilter.blur(sigmaX: 3.0, sigmaY: 3.0),
161 child: Container(
112 - decoration: BoxDecoration(
113 - color:
114 - Theme.of(context).extension<AlertTheme>()!.backdropColor),
162 + decoration:
163 + BoxDecoration(color: Theme.of(context).extension<AlertTheme>()!.backdropColor),
164 child: Center(
165 child: GestureDetector(
166 onTap: () => null,
118 - child: ClipRRect(
119 - borderRadius: BorderRadius.all(Radius.circular(30)),
120 - child: Container(
121 - width: 300,
122 - color: Theme.of(context).dialogBackgroundColor,
123 - child: Column(
124 - mainAxisSize: MainAxisSize.min,
125 - children: <Widget>[
126 - Column(
127 - crossAxisAlignment: CrossAxisAlignment.center,
128 - children: <Widget>[
129 - Padding(
130 - padding: EdgeInsets.fromLTRB(24, 20, 24, 0),
131 - child: title(context),
132 - ),
133 - isDividerExists
134 - ? Padding(
135 - padding: EdgeInsets.only(top: 16, bottom: 8),
136 - child: const HorizontalSectionDivider(),
137 - )
138 - : Offstage(),
139 - Padding(
140 - padding: EdgeInsets.fromLTRB(24, 8, 24, 32),
141 - child: content(context),
142 - )
143 - ],
144 - ),
145 - const HorizontalSectionDivider(),
146 - actionButtons(context)
147 - ],
148 - ),
167 + child: Container(
168 + decoration: BoxDecoration(
169 + borderRadius: BorderRadius.circular(30),
170 + color: Theme.of(context).dialogBackgroundColor),
171 + width: 300,
172 + child: Stack(
173 + clipBehavior: Clip.none,
174 + children: [
175 + if (headerImageUrl != null) headerImage(context, headerImageUrl!),
176 + Column(
177 + mainAxisSize: MainAxisSize.min,
178 + children: <Widget>[
179 + if (headerImageUrl != null) const SizedBox(height: 50),
180 + Column(
181 + crossAxisAlignment: CrossAxisAlignment.center,
182 + children: <Widget>[
183 + if (headerText != null) headerTitle(context),
184 + Padding(
185 + padding: EdgeInsets.fromLTRB(24, 20, 24, 0),
186 + child: title(context),
187 + ),
188 + isDividerExists
189 + ? Padding(
190 + padding: EdgeInsets.only(top: 16, bottom: 8),
191 + child: const HorizontalSectionDivider(),
192 + )
193 + : Offstage(),
194 + Padding(
195 + padding: EdgeInsets.fromLTRB(24, 8, 24, 32),
196 + child: content(context),
197 + )
198 + ],
199 + ),
200 + const HorizontalSectionDivider(),
201 + ClipRRect(
202 + borderRadius: BorderRadius.all(Radius.circular(30)),
203 + child: actionButtons(context))
204 + ],
205 + ),
206 + ],
207 ),
208 ),
209 ),
lib/twitter/twitter_api.dart
+1 -1
@@ -12,7 +12,7 @@ class TwitterApi {
12
13 static Future<TwitterUser> lookupUserByName({required String userName}) async {
14 final queryParams = {
15 - 'user.fields': 'description',
15 + 'user.fields': 'description,profile_image_url',
16 'expansions': 'pinned_tweet_id',
17 'tweet.fields': 'note_tweet'
18 };
lib/twitter/twitter_user.dart
+6 -1
@@ -4,20 +4,25 @@ class TwitterUser {
4 required this.username,
5 required this.name,
6 required this.description,
7 + required this.profileImageUrl,
8 this.pinnedTweet});
9
10 final String id;
11 final String username;
12 final String name;
13 final String description;
14 + final String profileImageUrl;
15 final Tweet? pinnedTweet;
16
17 factory TwitterUser.fromJson(Map<String, dynamic> json, [Tweet? pinnedTweet]) {
18 + final profileImageUrl = json['data']['profile_image_url'] as String? ?? '';
19 + final scaledProfileImageUrl = profileImageUrl.replaceFirst('normal', '200x200');
20 return TwitterUser(
21 id: json['data']['id'] as String,
18 - username: json['data']['username'] as String,
22 + username: json['data']['username'] as String? ?? '',
23 name: json['data']['name'] as String,
24 description: json['data']['description'] as String? ?? '',
25 + profileImageUrl: scaledProfileImageUrl,
26 pinnedTweet: pinnedTweet,
27 );
28 }