CW-1021-Address-formatting-enhancements (#2141)

* Implement visual formatting for addresses

* fix minor issues

* Update transaction_details_page.dart

* Update transaction_details_page.dart

* fix multi recipient address formatting

* Update address_cell.dart
This commit is contained in:
Serhii 2025-04-10 17:16:43 +03:00 committed by GitHub
parent c1e9668b1e
commit 3f25d69244
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 292 additions and 123 deletions

View file

@ -381,6 +381,7 @@ class CakePayBuyCardDetailPage extends BasePage {
return ConfirmSendingBottomSheet(
key: ValueKey('send_page_confirm_sending_dialog_key'),
currentTheme: currentTheme,
walletType: cakePayPurchaseViewModel.sendViewModel.walletType,
paymentId: S.of(popupContext).payment_id,
paymentIdValue: order?.orderId,
expirationTime: cakePayPurchaseViewModel.formattedRemainingTime,

View file

@ -264,6 +264,7 @@ class ExchangeTradeState extends State<ExchangeTradeForm> {
return ConfirmSendingBottomSheet(
key: ValueKey('exchange_trade_page_confirm_sending_bottom_sheet_key'),
currentTheme: widget.currentTheme,
walletType: widget.exchangeTradeViewModel.sendViewModel.walletType,
titleText: S.of(bottomSheetContext).confirm_transaction,
titleIconPath:
widget.exchangeTradeViewModel.sendViewModel.selectedCryptoCurrency.iconPath,

View file

@ -20,6 +20,7 @@ class AddressListPage extends BasePage {
children: <Widget>[
AddressList(
addressListViewModel: addressListViewModel,
currentTheme: currentTheme,
onSelect: (String address) async {
Navigator.of(context).pop(address);
},

View file

@ -124,7 +124,7 @@ class ReceivePage extends BasePage {
isLight: currentTheme.type == ThemeType.light,
),
),
AddressList(addressListViewModel: addressListViewModel),
AddressList(addressListViewModel: addressListViewModel, currentTheme: currentTheme),
Padding(
padding: EdgeInsets.fromLTRB(24, 24, 24, 32),
child: Text(

View file

@ -1,7 +1,11 @@
import 'package:auto_size_text/auto_size_text.dart';
import 'package:cake_wallet/generated/i18n.dart';
import 'package:cake_wallet/themes/extensions/qr_code_theme.dart';
import 'package:cake_wallet/themes/extensions/receive_page_theme.dart';
import 'package:cake_wallet/themes/theme_base.dart';
import 'package:cake_wallet/utils/address_formatter.dart';
import 'package:cake_wallet/utils/responsive_layout_util.dart';
import 'package:cake_wallet/view_model/wallet_address_list/wallet_address_list_item.dart';
import 'package:cw_core/wallet_type.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_slidable/flutter_slidable.dart';
@ -14,6 +18,8 @@ class AddressCell extends StatelessWidget {
required this.isPrimary,
required this.backgroundColor,
required this.textColor,
required this.walletType,
required this.currentTheme,
this.onTap,
this.onEdit,
this.onHide,
@ -30,6 +36,8 @@ class AddressCell extends StatelessWidget {
required bool isCurrent,
required Color backgroundColor,
required Color textColor,
required WalletType walletType,
required ThemeBase currentTheme,
Function(String)? onTap,
bool hasBalance = false,
bool hasReceived = false,
@ -45,6 +53,8 @@ class AddressCell extends StatelessWidget {
isPrimary: item.isPrimary,
backgroundColor: backgroundColor,
textColor: textColor,
walletType: walletType,
currentTheme: currentTheme,
onTap: onTap,
onEdit: onEdit,
onHide: onHide,
@ -62,6 +72,8 @@ class AddressCell extends StatelessWidget {
final bool isPrimary;
final Color backgroundColor;
final Color textColor;
final WalletType walletType;
final ThemeBase currentTheme;
final Function(String)? onTap;
final Function()? onEdit;
final Function()? onHide;
@ -73,21 +85,6 @@ class AddressCell extends StatelessWidget {
final bool hasBalance;
final bool hasReceived;
static const int addressPreviewLength = 8;
String get formattedAddress {
final formatIfCashAddr = address.replaceAll('bitcoincash:', '');
if (formatIfCashAddr.length <= (name.isNotEmpty ? 16 : 43)) {
return formatIfCashAddr;
} else {
return formatIfCashAddr.substring(0, addressPreviewLength) +
'...' +
formatIfCashAddr.substring(
formatIfCashAddr.length - addressPreviewLength, formatIfCashAddr.length);
}
}
@override
Widget build(BuildContext context) {
final Widget cell = InkWell(
@ -139,16 +136,14 @@ class AddressCell extends StatelessWidget {
],
),
Flexible(
child: AutoSizeText(
responsiveLayoutUtil.shouldRenderTabletUI ? address : formattedAddress,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: isChange ? 10 : 14,
color: textColor,
),
),
),
child: AddressFormatter.buildSegmentedAddress(
address: address,
walletType: walletType,
shouldTruncate: name.isNotEmpty || address.length > 43 ,
evenTextStyle: TextStyle(
fontSize: isChange ? 10 : 14,
color: textColor
))),
],
),
if (hasBalance || hasReceived)

View file

@ -1,5 +1,3 @@
import 'dart:math';
import 'package:cake_wallet/di.dart';
import 'package:cake_wallet/generated/i18n.dart';
import 'package:cake_wallet/routes.dart';
@ -9,6 +7,7 @@ import 'package:cake_wallet/src/screens/receive/widgets/address_cell.dart';
import 'package:cake_wallet/src/screens/receive/widgets/header_tile.dart';
import 'package:cake_wallet/src/widgets/section_divider.dart';
import 'package:cake_wallet/themes/extensions/receive_page_theme.dart';
import 'package:cake_wallet/themes/theme_base.dart';
import 'package:cake_wallet/utils/list_item.dart';
import 'package:cake_wallet/utils/show_pop_up.dart';
import 'package:cake_wallet/view_model/wallet_address_list/wallet_account_list_header.dart';
@ -20,16 +19,17 @@ import 'package:cw_core/wallet_type.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:mobx/mobx.dart';
class AddressList extends StatefulWidget {
const AddressList({
super.key,
required this.addressListViewModel,
required this.currentTheme,
this.onSelect,
});
final WalletAddressListViewModel addressListViewModel;
final ThemeBase currentTheme;
final Function(String)? onSelect;
@override
@ -161,6 +161,8 @@ class _AddressListState extends State<AddressList> {
return AddressCell.fromItem(
item,
isCurrent: isCurrent,
currentTheme: widget.currentTheme,
walletType: widget.addressListViewModel.type,
hasBalance: widget.addressListViewModel.isBalanceAvailable,
hasReceived: widget.addressListViewModel.isReceivedAvailable,
// hasReceived:

View file

@ -5,6 +5,7 @@ import 'package:cake_wallet/routes.dart';
import 'package:cake_wallet/src/screens/exchange/widgets/currency_picker.dart';
import 'package:cake_wallet/src/screens/receive/widgets/currency_input_field.dart';
import 'package:cake_wallet/themes/theme_base.dart';
import 'package:cake_wallet/utils/address_formatter.dart';
import 'package:cake_wallet/utils/brightness_util.dart';
import 'package:cake_wallet/utils/responsive_layout_util.dart';
import 'package:cake_wallet/utils/show_bar.dart';
@ -160,16 +161,15 @@ class QRWidget extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Expanded(
child: Text(
addressUri.address,
child: AddressFormatter.buildSegmentedAddress(
address: addressUri.address,
walletType: addressListViewModel.type,
textAlign: TextAlign.center,
style: TextStyle(
evenTextStyle: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w500,
color:
Theme.of(context).extension<DashboardPageTheme>()!.textColor),
),
),
Theme.of(context).extension<DashboardPageTheme>()!.textColor))),
Padding(
padding: EdgeInsets.only(left: 12),
child: copyImage,

View file

@ -558,6 +558,7 @@ class SendPage extends BasePage {
key: ValueKey('send_page_confirm_sending_dialog_key'),
titleText: S.of(bottomSheetContext).confirm_transaction,
currentTheme: currentTheme,
walletType: sendViewModel.walletType,
titleIconPath: sendViewModel.selectedCryptoCurrency.iconPath,
currency: sendViewModel.selectedCryptoCurrency,
amount: S.of(bottomSheetContext).send_amount,

View file

@ -190,6 +190,7 @@ class RBFDetailsPage extends BasePage {
key: ValueKey('rbf_confirm_sending_bottom_sheet'),
titleText: S.of(bottomSheetContext).confirm_transaction,
currentTheme: currentTheme,
walletType: transactionDetailsViewModel.sendViewModel.walletType,
titleIconPath: transactionDetailsViewModel.sendViewModel.selectedCryptoCurrency.iconPath,
currency: transactionDetailsViewModel.sendViewModel.selectedCryptoCurrency,
amount: S.of(bottomSheetContext).send_amount,

View file

@ -9,8 +9,11 @@ import 'package:cake_wallet/src/screens/transaction_details/textfield_list_item.
import 'package:cake_wallet/src/screens/transaction_details/widgets/textfield_list_row.dart';
import 'package:cake_wallet/src/widgets/list_row.dart';
import 'package:cake_wallet/src/widgets/standard_list.dart';
import 'package:cake_wallet/themes/extensions/cake_text_theme.dart';
import 'package:cake_wallet/utils/address_formatter.dart';
import 'package:cake_wallet/utils/show_bar.dart';
import 'package:cake_wallet/view_model/transaction_details_view_model.dart';
import 'package:cw_core/wallet_type.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
@ -39,13 +42,28 @@ class TransactionDetailsPage extends BasePage {
final item = transactionDetailsViewModel.items[index];
if (item is StandartListItem) {
Widget? addressTextWidget;
if (item.title.toLowerCase() == 'recipient addresses' ||
item.title.toLowerCase() == 'source address') {
addressTextWidget = getFormattedAddress(
context: context,
value: item.value,
walletType: transactionDetailsViewModel.sendViewModel.walletType,
);
}
return GestureDetector(
key: item.key,
onTap: () {
Clipboard.setData(ClipboardData(text: item.value));
showBar<void>(context, S.of(context).transaction_details_copied(item.title));
},
child: ListRow(title: '${item.title}:', value: item.value),
child: ListRow(
title: '${item.title}:',
value: item.value,
textWidget: addressTextWidget,
),
);
}
@ -91,4 +109,80 @@ class TransactionDetailsPage extends BasePage {
],
);
}
Widget getFormattedAddress({
required BuildContext context,
required String value,
required WalletType walletType,
}) {
final textStyle = TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
color: Theme.of(context).extension<CakeTextTheme>()!.titleColor,
);
final List<Widget> children = [];
final bool hasDoubleNewline = value.contains('\n\n');
if (hasDoubleNewline) {
final blocks = value
.split('\n\n')
.map((b) => b.trim())
.where((b) => b.isNotEmpty)
.toList();
for (final block in blocks) {
final lines = block
.split('\n')
.map((l) => l.trim())
.where((l) => l.isNotEmpty)
.toList();
if (lines.length > 1) {
children.add(Text(lines.first, style: textStyle));
for (int i = 1; i < lines.length; i++) {
children.add(
AddressFormatter.buildSegmentedAddress(
address: lines[i],
walletType: walletType,
evenTextStyle: textStyle,
),
);
}
} else {
children.add(
AddressFormatter.buildSegmentedAddress(
address: lines.first,
walletType: walletType,
evenTextStyle: textStyle,
),
);
}
children.add(SizedBox(height: 8));
}
} else {
final lines = value
.split('\n')
.map((l) => l.trim())
.where((l) => l.isNotEmpty)
.toList();
bool firstLineIsContactName = (lines.length > 1 && lines.first.length < 20);
int startIndex = 0;
if (firstLineIsContactName) {
children.add(Text(lines.first, style: textStyle));
startIndex = 1;
}
for (int i = startIndex; i < lines.length; i++) {
children.add(
AddressFormatter.buildSegmentedAddress(
address: lines[i],
walletType: walletType,
evenTextStyle: textStyle,
),
);
}
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: children,
);
}
}

View file

@ -5,11 +5,12 @@ import 'package:cake_wallet/themes/extensions/cake_text_theme.dart';
import 'package:cake_wallet/themes/extensions/filter_theme.dart';
import 'package:cake_wallet/themes/extensions/sync_indicator_theme.dart';
import 'package:cake_wallet/themes/theme_base.dart';
import 'package:cake_wallet/utils/address_formatter.dart';
import 'package:cake_wallet/view_model/send/output.dart';
import 'package:cw_core/crypto_currency.dart';
import 'package:cw_core/pending_transaction.dart';
import 'package:cw_core/wallet_type.dart';
import 'package:flutter/material.dart';
import 'dart:math' as math;
import 'base_bottom_sheet_widget.dart';
@ -27,6 +28,7 @@ class ConfirmSendingBottomSheet extends BaseBottomSheet {
final String feeFiatAmount;
final List<Output> outputs;
final VoidCallback onSlideComplete;
final WalletType walletType;
final PendingChange? change;
final bool isOpenCryptoPay;
@ -46,6 +48,7 @@ class ConfirmSendingBottomSheet extends BaseBottomSheet {
required this.feeFiatAmount,
required this.outputs,
required this.onSlideComplete,
required this.walletType,
this.change,
this.isOpenCryptoPay = false,
Key? key,
@ -91,6 +94,7 @@ class ConfirmSendingBottomSheet extends BaseBottomSheet {
itemTitle: paymentId!,
currentTheme: currentTheme,
itemTitleTextStyle: itemTitleTextStyle,
walletType: walletType,
isBatchSending: false,
amount: '',
address: paymentIdValue!,
@ -139,6 +143,7 @@ class ConfirmSendingBottomSheet extends BaseBottomSheet {
name: isBatchSending ? batchContactTitle : contactName,
address: _address,
amount: _amount,
walletType: walletType,
isBatchSending: isBatchSending,
itemTitleTextStyle: itemTitleTextStyle,
itemSubTitleTextStyle: itemSubTitleTextStyle,
@ -149,6 +154,7 @@ class ConfirmSendingBottomSheet extends BaseBottomSheet {
currentTheme: currentTheme,
itemTitleTextStyle: itemTitleTextStyle,
isBatchSending: isBatchSending,
walletType: walletType,
amount: _amount,
address: _address,
itemSubTitleTextStyle: itemSubTitleTextStyle,
@ -166,6 +172,7 @@ class ConfirmSendingBottomSheet extends BaseBottomSheet {
address: change!.address,
amount: change!.amount + ' ${currency.title}',
isBatchSending: true,
walletType: walletType,
itemTitleTextStyle: itemTitleTextStyle,
itemSubTitleTextStyle: itemSubTitleTextStyle,
tileBackgroundColor: tileBackgroundColor,
@ -275,6 +282,7 @@ class AddressTile extends StatelessWidget {
required this.address,
required this.itemSubTitleTextStyle,
required this.tileBackgroundColor,
required this.walletType,
});
final String itemTitle;
@ -285,18 +293,10 @@ class AddressTile extends StatelessWidget {
final String address;
final TextStyle itemSubTitleTextStyle;
final Color tileBackgroundColor;
final WalletType walletType;
@override
Widget build(BuildContext context) {
final addressTextStyle = TextStyle(
fontSize: 12,
fontFamily: 'Lato',
fontWeight: FontWeight.w600,
color: currentTheme.type == ThemeType.bright
? Theme.of(context).extension<CakeTextTheme>()!.titleColor.withOpacity(0.5)
: Theme.of(context).extension<CakeTextTheme>()!.titleColor,
decoration: TextDecoration.none,
);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
decoration: BoxDecoration(
@ -313,41 +313,20 @@ class AddressTile extends StatelessWidget {
if (isBatchSending) Text(amount, style: itemTitleTextStyle),
],
),
buildSegmentedAddress(
AddressFormatter.buildSegmentedAddress(
address: address,
evenTextStyle: addressTextStyle,
oddTextStyle: itemSubTitleTextStyle,
walletType: walletType,
evenTextStyle: TextStyle(
fontSize: 12,
fontFamily: 'Lato',
fontWeight: FontWeight.w600,
color: Theme.of(context).extension<CakeTextTheme>()!.titleColor,
decoration: TextDecoration.none)
),
],
),
);
}
Widget buildSegmentedAddress({
required String address,
int chunkSize = 6,
required TextStyle evenTextStyle,
required TextStyle oddTextStyle,
}) {
final spans = <TextSpan>[];
int index = 0;
for (int i = 0; i < address.length; i += chunkSize) {
final chunk = address.substring(i, math.min(i + chunkSize, address.length));
final style = (index % 2 == 0) ? evenTextStyle : oddTextStyle;
spans.add(
TextSpan(text: '$chunk ', style: style),
);
index++;
}
return RichText(
text: TextSpan(children: spans, style: evenTextStyle),
overflow: TextOverflow.visible,
);
}
}
class AddressExpansionTile extends StatelessWidget {
@ -362,6 +341,7 @@ class AddressExpansionTile extends StatelessWidget {
required this.itemTitleTextStyle,
required this.itemSubTitleTextStyle,
required this.tileBackgroundColor,
required this.walletType,
});
final String contactType;
@ -373,19 +353,10 @@ class AddressExpansionTile extends StatelessWidget {
final TextStyle itemTitleTextStyle;
final TextStyle itemSubTitleTextStyle;
final Color tileBackgroundColor;
final WalletType walletType;
@override
Widget build(BuildContext context) {
final addressTextStyle = TextStyle(
fontSize: 12,
fontFamily: 'Lato',
fontWeight: FontWeight.w600,
color: currentTheme.type == ThemeType.bright
? Theme.of(context).extension<CakeTextTheme>()!.titleColor.withOpacity(0.5)
: Theme.of(context).extension<CakeTextTheme>()!.titleColor,
decoration: TextDecoration.none,
);
return Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.all(Radius.circular(10)),
@ -420,10 +391,15 @@ class AddressExpansionTile extends StatelessWidget {
Row(
children: [
Expanded(
child: buildSegmentedAddress(
address: address,
evenTextStyle: addressTextStyle,
oddTextStyle: itemSubTitleTextStyle,
child: AddressFormatter.buildSegmentedAddress(
address: address,
walletType: walletType,
evenTextStyle: TextStyle(
fontSize: 12,
fontFamily: 'Lato',
fontWeight: FontWeight.w600,
color: Theme.of(context).extension<CakeTextTheme>()!.titleColor,
decoration: TextDecoration.none)
),
),
],
@ -434,30 +410,4 @@ class AddressExpansionTile extends StatelessWidget {
),
);
}
Widget buildSegmentedAddress({
required String address,
int chunkSize = 6,
required TextStyle evenTextStyle,
required TextStyle oddTextStyle,
}) {
final spans = <TextSpan>[];
int index = 0;
for (int i = 0; i < address.length; i += chunkSize) {
final chunk = address.substring(i, math.min(i + chunkSize, address.length));
final style = (index % 2 == 0) ? evenTextStyle : oddTextStyle;
spans.add(
TextSpan(text: '$chunk ', style: style),
);
index++;
}
return RichText(
text: TextSpan(children: spans, style: evenTextStyle),
overflow: TextOverflow.visible,
);
}
}

View file

@ -12,7 +12,8 @@ class ListRow extends StatelessWidget {
this.padding,
this.color,
this.hintTextColor,
this.mainTextColor
this.mainTextColor,
this.textWidget
});
final String title;
@ -24,6 +25,16 @@ class ListRow extends StatelessWidget {
final Color? color;
final Color? hintTextColor;
final Color? mainTextColor;
final Widget? textWidget;
Widget _getTextWidget (BuildContext context) => textWidget ?? Text(
value,
style: TextStyle(
fontSize: valueFontSize,
fontWeight: FontWeight.w500,
color: mainTextColor ?? Theme.of(context).extension<CakeTextTheme>()!.titleColor
),
);
@override
Widget build(BuildContext context) {
@ -49,12 +60,7 @@ class ListRow extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Expanded(
child: Text(value,
style: TextStyle(
fontSize: valueFontSize,
fontWeight: FontWeight.w500,
color: mainTextColor ?? Theme.of(context).extension<CakeTextTheme>()!.titleColor)),
),
child: _getTextWidget(context)),
image != null
? Padding(
padding: EdgeInsets.only(left: 24),

View file

@ -0,0 +1,117 @@
import 'package:cw_core/wallet_type.dart';
import 'package:flutter/widgets.dart';
import 'dart:math' as math;
class AddressFormatter {
static Widget buildSegmentedAddress({
required String address,
required WalletType walletType,
required TextStyle evenTextStyle,
TextStyle? oddTextStyle,
TextAlign? textAlign,
bool shouldTruncate = false,
}) {
if (shouldTruncate) {
return _buildTruncatedAddress(
address: address,
walletType: walletType,
evenTextStyle: evenTextStyle,
oddTextStyle: oddTextStyle ?? evenTextStyle.copyWith(color: evenTextStyle.color!.withAlpha(150)),
textAlign: textAlign,
);
} else {
return _buildFullSegmentedAddress(
address: address,
walletType: walletType,
evenTextStyle: evenTextStyle,
oddTextStyle: oddTextStyle ?? evenTextStyle.copyWith(color: evenTextStyle.color!.withAlpha(128)),
textAlign: textAlign,
);
}
}
static Widget _buildFullSegmentedAddress({
required String address,
required WalletType walletType,
required TextStyle evenTextStyle,
required TextStyle oddTextStyle,
TextAlign? textAlign,
}) {
final cleanAddress = address.replaceAll('bitcoincash:', '');
final chunkSize = _getChunkSize(walletType);
final chunks = <String>[];
for (int i = 0; i < cleanAddress.length; i += chunkSize) {
final chunk = cleanAddress.substring(i, math.min(i + chunkSize, cleanAddress.length));
chunks.add(chunk);
}
final spans = <TextSpan>[];
for (int i = 0; i < chunks.length; i++) {
final style = (i % 2 == 0) ? evenTextStyle : oddTextStyle;
spans.add(TextSpan(text: '${chunks[i]} ', style: style));
}
return RichText(
text: TextSpan(children: spans),
textAlign: textAlign ?? TextAlign.start,
overflow: TextOverflow.visible,
);
}
static Widget _buildTruncatedAddress({
required String address,
required WalletType walletType,
required TextStyle evenTextStyle,
required TextStyle oddTextStyle,
TextAlign? textAlign,
}) {
final cleanAddress = address.replaceAll('bitcoincash:', '');
final int digitCount = (walletType == WalletType.monero ||
walletType == WalletType.wownero ||
walletType == WalletType.zano)
? 6
: 4;
if (cleanAddress.length <= 2 * digitCount) {
return _buildFullSegmentedAddress(
address: cleanAddress,
walletType: walletType,
evenTextStyle: evenTextStyle,
oddTextStyle: oddTextStyle,
textAlign: textAlign,
);
}
final String firstPart = cleanAddress.substring(0, digitCount);
final String secondPart = cleanAddress.substring(digitCount, digitCount * 2);
final String lastPart = cleanAddress.substring(cleanAddress.length - digitCount);
final spans = <TextSpan>[
TextSpan(text: '$firstPart ', style: evenTextStyle),
TextSpan(text: '$secondPart ', style: oddTextStyle),
TextSpan(text: '... ', style: oddTextStyle),
TextSpan(text: lastPart, style: evenTextStyle),
];
return RichText(
text: TextSpan(children: spans),
textAlign: textAlign ?? TextAlign.start,
overflow: TextOverflow.visible,
);
}
static int _getChunkSize(WalletType walletType) {
switch (walletType) {
case WalletType.monero:
case WalletType.wownero:
case WalletType.zano:
return 6;
default:
return 4;
}
}
}