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
}