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

* Update address_validator.dart

* add twitter profile image

* mastodon profile image

* fix data types
This commit is contained in:
Serhii 2024-03-01 21:38:48 +02:00 committed by GitHub
parent 2549b0fa0a
commit 10fd32fb2e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 200 additions and 86 deletions

View file

@ -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]|^)8[0-9a-zA-Z]{94}([^0-9a-zA-Z]|\$)'
'|([^0-9a-zA-Z]|^)[0-9a-zA-Z]{106}([^0-9a-zA-Z]|\$)'; '|([^0-9a-zA-Z]|^)[0-9a-zA-Z]{106}([^0-9a-zA-Z]|\$)';
case CryptoCurrency.btc: case CryptoCurrency.btc:
return '([^0-9a-zA-Z]|^)${P2pkhAddress.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]|^)${P2shAddress.regex.pattern}|\$)' '|([^0-9a-zA-Z]|^)([23][a-km-zA-HJ-NP-Z1-9]{25,34})([^0-9a-zA-Z]|\$)' //P2shAddress type
'([^0-9a-zA-Z]|^)${P2wpkhAddress.regex.pattern}|\$)' '|([^0-9a-zA-Z]|^)((bc|tb)1q[ac-hj-np-z02-9]{25,39})([^0-9a-zA-Z]|\$)' //P2wpkhAddress type
'([^0-9a-zA-Z]|^)${P2wshAddress.regex.pattern}|\$)' '|([^0-9a-zA-Z]|^)((bc|tb)1q[ac-hj-np-z02-9]{40,80})([^0-9a-zA-Z]|\$)' //P2wshAddress type
'([^0-9a-zA-Z]|^)${P2trAddress.regex.pattern}|\$)'; '|([^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: case CryptoCurrency.ltc:
return '([^0-9a-zA-Z]|^)^L[a-zA-Z0-9]{26,33}([^0-9a-zA-Z]|\$)' 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]|\$)' '|([^0-9a-zA-Z]|^)[LM][a-km-zA-HJ-NP-Z1-9]{26,33}([^0-9a-zA-Z]|\$)'

View file

@ -69,16 +69,20 @@ class AddressResolver {
} }
Future<ParsedAddress> resolve(BuildContext context, String text, String ticker) async { Future<ParsedAddress> resolve(BuildContext context, String text, String ticker) async {
try { try {
if (text.startsWith('@') && !text.substring(1).contains('@')) { if (text.startsWith('@') && !text.substring(1).contains('@')) {
if(settingsStore.lookupsTwitter) { if (settingsStore.lookupsTwitter) {
final formattedName = text.substring(1); final formattedName = text.substring(1);
final twitterUser = await TwitterApi.lookupUserByName(userName: formattedName); final twitterUser = await TwitterApi.lookupUserByName(userName: formattedName);
final addressFromBio = extractAddressByType( final addressFromBio = extractAddressByType(
raw: twitterUser.description, type: CryptoCurrency.fromString(ticker)); raw: twitterUser.description, type: CryptoCurrency.fromString(ticker));
if (addressFromBio != null) { 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; final pinnedTweet = twitterUser.pinnedTweet?.text;
@ -86,7 +90,11 @@ class AddressResolver {
final addressFromPinnedTweet = final addressFromPinnedTweet =
extractAddressByType(raw: pinnedTweet, type: CryptoCurrency.fromString(ticker)); extractAddressByType(raw: pinnedTweet, type: CryptoCurrency.fromString(ticker));
if (addressFromPinnedTweet != null) { 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)); extractAddressByType(raw: mastodonUser.note, type: CryptoCurrency.fromString(ticker));
if (addressFromBio != null) { if (addressFromBio != null) {
return ParsedAddress.fetchMastodonAddress(address: addressFromBio, name: text); return ParsedAddress.fetchMastodonAddress(
address: addressFromBio,
name: text,
profileImageUrl: mastodonUser.profileImageUrl,
profileName: mastodonUser.username);
} else { } else {
final pinnedPosts = final pinnedPosts =
await MastodonAPI.getPinnedPosts(userId: mastodonUser.id, apiHost: hostName); await MastodonAPI.getPinnedPosts(userId: mastodonUser.id, apiHost: hostName);
@ -119,7 +131,10 @@ class AddressResolver {
if (addressFromPinnedPost != null) { if (addressFromPinnedPost != null) {
return ParsedAddress.fetchMastodonAddress( return ParsedAddress.fetchMastodonAddress(
address: addressFromPinnedPost, name: text); address: addressFromPinnedPost,
name: text,
profileImageUrl: mastodonUser.profileImageUrl,
profileName: mastodonUser.username);
} }
} }
} }

View file

@ -1,7 +1,6 @@
import 'package:cake_wallet/entities/openalias_record.dart'; import 'package:cake_wallet/entities/openalias_record.dart';
import 'package:cake_wallet/entities/yat_record.dart'; import 'package:cake_wallet/entities/yat_record.dart';
enum ParseFrom { enum ParseFrom {
unstoppableDomains, unstoppableDomains,
openAlias, openAlias,
@ -20,36 +19,37 @@ class ParsedAddress {
required this.addresses, required this.addresses,
this.name = '', this.name = '',
this.description = '', this.description = '',
this.profileImageUrl = '',
this.profileName = '',
this.parseFrom = ParseFrom.notParsed, this.parseFrom = ParseFrom.notParsed,
}); });
factory ParsedAddress.fetchEmojiAddress({ factory ParsedAddress.fetchEmojiAddress({
List<YatRecord>? addresses, List<YatRecord>? addresses,
required String name, required String name,
}){ }) {
if (addresses?.isEmpty ?? true) { if (addresses?.isEmpty ?? true) {
return ParsedAddress( return ParsedAddress(addresses: [name], parseFrom: ParseFrom.yatRecord);
addresses: [name], parseFrom: ParseFrom.yatRecord); }
} return ParsedAddress(
return ParsedAddress( addresses: addresses!.map((e) => e.address).toList(),
addresses: addresses!.map((e) => e.address).toList(), name: name,
name: name, parseFrom: ParseFrom.yatRecord,
parseFrom: ParseFrom.yatRecord, );
);
} }
factory ParsedAddress.fetchUnstoppableDomainAddress({ factory ParsedAddress.fetchUnstoppableDomainAddress({
String? address, String? address,
required String name, required String name,
}){ }) {
if (address?.isEmpty ?? true) { if (address?.isEmpty ?? true) {
return ParsedAddress(addresses: [name]); return ParsedAddress(addresses: [name]);
} }
return ParsedAddress( return ParsedAddress(
addresses: [address!], addresses: [address!],
name: name, name: name,
parseFrom: ParseFrom.unstoppableDomains, parseFrom: ParseFrom.unstoppableDomains,
); );
} }
factory ParsedAddress.fetchOpenAliasAddress( 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( return ParsedAddress(
addresses: [address], addresses: [address],
name: name, 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( return ParsedAddress(
addresses: [address], addresses: [address],
name: name, name: name,
description: description ?? '',
profileImageUrl: profileImageUrl,
profileName: profileName,
parseFrom: ParseFrom.twitter, 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( return ParsedAddress(
addresses: [address], addresses: [address],
name: name, 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( return ParsedAddress(
addresses: [address], addresses: [address],
name: name, name: name,
@ -116,6 +130,7 @@ class ParsedAddress {
final List<String> addresses; final List<String> addresses;
final String name; final String name;
final String description; final String description;
final String profileImageUrl;
final String profileName;
final ParseFrom parseFrom; final ParseFrom parseFrom;
} }

View file

@ -1,12 +1,14 @@
class MastodonUser { class MastodonUser {
String id; String id;
String username; String username;
String profileImageUrl;
String acct; String acct;
String note; String note;
MastodonUser({ MastodonUser({
required this.id, required this.id,
required this.username, required this.username,
required this.profileImageUrl,
required this.acct, required this.acct,
required this.note, required this.note,
}); });
@ -14,9 +16,10 @@ class MastodonUser {
factory MastodonUser.fromJson(Map<String, dynamic> json) { factory MastodonUser.fromJson(Map<String, dynamic> json) {
return MastodonUser( return MastodonUser(
id: json['id'] as String, id: json['id'] as String,
username: json['username'] as String, username: json['username'] as String? ?? '',
acct: json['acct'] as String, acct: json['acct'] as String,
note: json['note'] as String, note: json['note'] as String,
profileImageUrl: json['avatar'] as String? ?? ''
); );
} }
} }

View file

@ -11,6 +11,8 @@ Future<String> extractAddressFromParsed(
var title = ''; var title = '';
var content = ''; var content = '';
var address = ''; var address = '';
var profileImageUrl = '';
var profileName = '';
switch (parsedAddress.parseFrom) { switch (parsedAddress.parseFrom) {
case ParseFrom.unstoppableDomains: case ParseFrom.unstoppableDomains:
@ -37,11 +39,15 @@ Future<String> extractAddressFromParsed(
title = S.of(context).address_detected; title = S.of(context).address_detected;
content = S.of(context).extracted_address_content('${parsedAddress.name} (Twitter)'); content = S.of(context).extracted_address_content('${parsedAddress.name} (Twitter)');
address = parsedAddress.addresses.first; address = parsedAddress.addresses.first;
profileImageUrl = parsedAddress.profileImageUrl;
profileName = parsedAddress.profileName;
break; break;
case ParseFrom.mastodon: case ParseFrom.mastodon:
title = S.of(context).address_detected; title = S.of(context).address_detected;
content = S.of(context).extracted_address_content('${parsedAddress.name} (Mastodon)'); content = S.of(context).extracted_address_content('${parsedAddress.name} (Mastodon)');
address = parsedAddress.addresses.first; address = parsedAddress.addresses.first;
profileImageUrl = parsedAddress.profileImageUrl;
profileName = parsedAddress.profileName;
break; break;
case ParseFrom.nostr: case ParseFrom.nostr:
title = S.of(context).address_detected; title = S.of(context).address_detected;
@ -95,6 +101,8 @@ Future<String> extractAddressFromParsed(
return AlertWithOneAction( return AlertWithOneAction(
alertTitle: title, alertTitle: title,
headerTitleText: profileName.isEmpty ? null : profileName,
headerImageProfileUrl: profileImageUrl.isEmpty ? null : profileImageUrl,
alertContent: content, alertContent: content,
buttonText: S.of(context).ok, buttonText: S.of(context).ok,
buttonAction: () => Navigator.of(context).pop()); buttonAction: () => Navigator.of(context).pop());

View file

@ -7,7 +7,9 @@ class AlertWithOneAction extends BaseAlertDialog {
required this.alertContent, required this.alertContent,
required this.buttonText, required this.buttonText,
required this.buttonAction, required this.buttonAction,
this.alertBarrierDismissible = true this.alertBarrierDismissible = true,
this.headerTitleText,
this.headerImageProfileUrl
}); });
final String alertTitle; final String alertTitle;
@ -15,6 +17,8 @@ class AlertWithOneAction extends BaseAlertDialog {
final String buttonText; final String buttonText;
final VoidCallback buttonAction; final VoidCallback buttonAction;
final bool alertBarrierDismissible; final bool alertBarrierDismissible;
final String? headerTitleText;
final String? headerImageProfileUrl;
@override @override
String get titleText => alertTitle; String get titleText => alertTitle;
@ -25,6 +29,12 @@ class AlertWithOneAction extends BaseAlertDialog {
@override @override
bool get barrierDismissible => alertBarrierDismissible; bool get barrierDismissible => alertBarrierDismissible;
@override
String? get headerImageUrl => headerImageProfileUrl;
@override
String? get headerText => headerTitleText;
@override @override
Widget actionButtons(BuildContext context) { Widget actionButtons(BuildContext context) {
return Container( return Container(

View file

@ -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 'package:cake_wallet/themes/extensions/cake_text_theme.dart';
import 'dart:ui'; import 'dart:ui';
import 'package:cake_wallet/src/widgets/section_divider.dart'; 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'; import 'package:flutter/material.dart';
class BaseAlertDialog extends StatelessWidget { class BaseAlertDialog extends StatelessWidget {
String? get headerText => '';
String get titleText => ''; String get titleText => '';
String get contentText => ''; String get contentText => '';
String get leftActionButtonText => ''; String get leftActionButtonText => '';
String get rightActionButtonText => ''; String get rightActionButtonText => '';
bool get isDividerExists => false; bool get isDividerExists => false;
VoidCallback get actionLeft => () {}; VoidCallback get actionLeft => () {};
VoidCallback get actionRight => () {}; VoidCallback get actionRight => () {};
bool get barrierDismissible => true; bool get barrierDismissible => true;
Color? get leftActionButtonTextColor => null; Color? get leftActionButtonTextColor => null;
Color? get rightActionButtonTextColor => null; Color? get rightActionButtonTextColor => null;
Color? get leftActionButtonColor => null; Color? get leftActionButtonColor => null;
Color? get rightActionButtonColor => null; Color? get rightActionButtonColor => null;
String? get headerImageUrl => null;
Widget title(BuildContext context) { Widget title(BuildContext context) {
return Text( return Text(
titleText, 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<CakeTextTheme>()!.titleColor,
decoration: TextDecoration.none,
),
),
);
}
Widget content(BuildContext context) { Widget content(BuildContext context) {
return Text( return Text(
contentText, contentText,
@ -48,17 +81,17 @@ class BaseAlertDialog extends StatelessWidget {
Widget actionButtons(BuildContext context) { Widget actionButtons(BuildContext context) {
return Container( return Container(
height: 60, height: 60,
child: Row( child: Row(
mainAxisSize: MainAxisSize.max, mainAxisSize: MainAxisSize.max,
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[ children: <Widget>[
Expanded( Expanded(
child: TextButton( child: TextButton(
onPressed: actionLeft, onPressed: actionLeft,
style: TextButton.styleFrom( style: TextButton.styleFrom(
backgroundColor: leftActionButtonColor ?? backgroundColor:
Theme.of(context).dialogBackgroundColor, leftActionButtonColor ?? Theme.of(context).dialogBackgroundColor,
shape: const RoundedRectangleBorder( shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.zero))), borderRadius: BorderRadius.all(Radius.zero))),
child: Text( child: Text(
@ -79,8 +112,8 @@ class BaseAlertDialog extends StatelessWidget {
child: TextButton( child: TextButton(
onPressed: actionRight, onPressed: actionRight,
style: TextButton.styleFrom( style: TextButton.styleFrom(
backgroundColor: rightActionButtonColor ?? backgroundColor:
Theme.of(context).dialogBackgroundColor, rightActionButtonColor ?? Theme.of(context).dialogBackgroundColor,
shape: const RoundedRectangleBorder( shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.zero))), borderRadius: BorderRadius.all(Radius.zero))),
child: Text( child: Text(
@ -90,8 +123,7 @@ class BaseAlertDialog extends StatelessWidget {
fontSize: 15, fontSize: 15,
fontFamily: 'Lato', fontFamily: 'Lato',
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: rightActionButtonTextColor ?? color: rightActionButtonTextColor ?? Theme.of(context).primaryColor,
Theme.of(context).primaryColor,
decoration: TextDecoration.none, 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return GestureDetector( return GestureDetector(
@ -109,43 +159,51 @@ class BaseAlertDialog extends StatelessWidget {
child: BackdropFilter( child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 3.0, sigmaY: 3.0), filter: ImageFilter.blur(sigmaX: 3.0, sigmaY: 3.0),
child: Container( child: Container(
decoration: BoxDecoration( decoration:
color: BoxDecoration(color: Theme.of(context).extension<AlertTheme>()!.backdropColor),
Theme.of(context).extension<AlertTheme>()!.backdropColor),
child: Center( child: Center(
child: GestureDetector( child: GestureDetector(
onTap: () => null, onTap: () => null,
child: ClipRRect( child: Container(
borderRadius: BorderRadius.all(Radius.circular(30)), decoration: BoxDecoration(
child: Container( borderRadius: BorderRadius.circular(30),
width: 300, color: Theme.of(context).dialogBackgroundColor),
color: Theme.of(context).dialogBackgroundColor, width: 300,
child: Column( child: Stack(
mainAxisSize: MainAxisSize.min, clipBehavior: Clip.none,
children: <Widget>[ children: [
Column( if (headerImageUrl != null) headerImage(context, headerImageUrl!),
crossAxisAlignment: CrossAxisAlignment.center, Column(
children: <Widget>[ mainAxisSize: MainAxisSize.min,
Padding( children: <Widget>[
padding: EdgeInsets.fromLTRB(24, 20, 24, 0), if (headerImageUrl != null) const SizedBox(height: 50),
child: title(context), Column(
), crossAxisAlignment: CrossAxisAlignment.center,
isDividerExists children: <Widget>[
? Padding( if (headerText != null) headerTitle(context),
padding: EdgeInsets.only(top: 16, bottom: 8), Padding(
child: const HorizontalSectionDivider(), padding: EdgeInsets.fromLTRB(24, 20, 24, 0),
) child: title(context),
: Offstage(), ),
Padding( isDividerExists
padding: EdgeInsets.fromLTRB(24, 8, 24, 32), ? Padding(
child: content(context), padding: EdgeInsets.only(top: 16, bottom: 8),
) child: const HorizontalSectionDivider(),
], )
), : Offstage(),
const HorizontalSectionDivider(), Padding(
actionButtons(context) padding: EdgeInsets.fromLTRB(24, 8, 24, 32),
], child: content(context),
), )
],
),
const HorizontalSectionDivider(),
ClipRRect(
borderRadius: BorderRadius.all(Radius.circular(30)),
child: actionButtons(context))
],
),
],
), ),
), ),
), ),

View file

@ -12,7 +12,7 @@ class TwitterApi {
static Future<TwitterUser> lookupUserByName({required String userName}) async { static Future<TwitterUser> lookupUserByName({required String userName}) async {
final queryParams = { final queryParams = {
'user.fields': 'description', 'user.fields': 'description,profile_image_url',
'expansions': 'pinned_tweet_id', 'expansions': 'pinned_tweet_id',
'tweet.fields': 'note_tweet' 'tweet.fields': 'note_tweet'
}; };

View file

@ -4,20 +4,25 @@ class TwitterUser {
required this.username, required this.username,
required this.name, required this.name,
required this.description, required this.description,
required this.profileImageUrl,
this.pinnedTweet}); this.pinnedTweet});
final String id; final String id;
final String username; final String username;
final String name; final String name;
final String description; final String description;
final String profileImageUrl;
final Tweet? pinnedTweet; final Tweet? pinnedTweet;
factory TwitterUser.fromJson(Map<String, dynamic> json, [Tweet? pinnedTweet]) { factory TwitterUser.fromJson(Map<String, dynamic> json, [Tweet? pinnedTweet]) {
final profileImageUrl = json['data']['profile_image_url'] as String? ?? '';
final scaledProfileImageUrl = profileImageUrl.replaceFirst('normal', '200x200');
return TwitterUser( return TwitterUser(
id: json['data']['id'] as String, id: json['data']['id'] as String,
username: json['data']['username'] as String, username: json['data']['username'] as String? ?? '',
name: json['data']['name'] as String, name: json['data']['name'] as String,
description: json['data']['description'] as String? ?? '', description: json['data']['description'] as String? ?? '',
profileImageUrl: scaledProfileImageUrl,
pinnedTweet: pinnedTweet, pinnedTweet: pinnedTweet,
); );
} }