add copy indicator on tx details screen (#3152)
What changed, and why it matters
This commit is a user-interface improvement that adds a visual 'Copied' indicator when users tap to copy text on transaction details screens. It does not change security-sensitive logic, fix a vulnerability, or introduce any obvious security risk.
No security action required; this is a routine UX enhancement. Standard code review for clipboard handling is sufficient.
Security signals we found
No strong security signals were identified.
Evidence from the diff
The patch introduces a new CopyWrapper widget that wraps tappable rows, calls Clipboard.setData, and briefly shows a ‘Copied’ label and background highlight. It refactors transaction_details_modal.dart to pass copyableText through the list row model instead of using an inline onTap handler, and threads the new copyableText property through the list row entity, section, style wrapper, and widget layers. No cryptographic, authentication, networking, or storage code is modified.
Changed components
lib/new-ui/widgets/copy_wrapper.dartlib/new-ui/widgets/coins_page/assets_history/transaction_details_modal.dartlib/entities/new_ui_entities/list_item/list_item_regular_row.dartlib/src/widgets/new_list_row/list_item_regular_row_widget.dartlib/src/widgets/new_list_row/list_Item_style_wrapper.dartlib/src/widgets/new_list_row/new_list_section.dartInspect captured patch +162 / −77
diff --git a/lib/entities/new_ui_entities/list_item/list_item_regular_row.dart b/lib/entities/new_ui_entities/list_item/list_item_regular_row.dart
index a9ef260d..f6df44a2 100644
--- a/lib/entities/new_ui_entities/list_item/list_item_regular_row.dart
+++ b/lib/entities/new_ui_entities/list_item/list_item_regular_row.dart
@@ -15,13 +15,15 @@ class ListItemRegularRow extends ListItem {
this.trailingWidget,
this.truncateTrailingText = false,
this.foregroundColor,
- this.trailingIconSize
+ this.trailingIconSize,
+ this.copyableText
});
final String? subtitle;
final String? trailingText;
final String? iconPath;
final String? trailingIconPath;
+ final String? copyableText;
final VoidCallback? onTap;
final bool showArrow;
final Widget? bottomWidget;
diff --git a/lib/new-ui/widgets/coins_page/assets_history/transaction_details_modal.dart b/lib/new-ui/widgets/coins_page/assets_history/transaction_details_modal.dart
index a4b94515..f7e6c952 100644
--- a/lib/new-ui/widgets/coins_page/assets_history/transaction_details_modal.dart
+++ b/lib/new-ui/widgets/coins_page/assets_history/transaction_details_modal.dart
@@ -99,10 +99,7 @@ class _TransactionDetailsModalState extends State<TransactionDetailsModal> {
item.value.length > 25;
return ListItemRegularRow(
- onTap: () {
- Clipboard.setData(
- ClipboardData(text: item.value));
- },
+ copyableText: item.value,
showArrow: false,
keyValue:
((item.key as ValueKey?)?.value as String?) ??
diff --git a/lib/new-ui/widgets/copy_wrapper.dart b/lib/new-ui/widgets/copy_wrapper.dart
new file mode 100644
index 00000000..254d02f5
--- /dev/null
+++ b/lib/new-ui/widgets/copy_wrapper.dart
@@ -0,0 +1,59 @@
+import 'dart:io';
+
+import 'package:device_info_plus/device_info_plus.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
+
+class CopyWrapper extends StatefulWidget {
+ const CopyWrapper(
+ {super.key,
+ this.data,
+ required this.builder,
+ this.duration = const Duration(milliseconds: 1200)});
+
+ final ClipboardData? data;
+ final Widget Function(BuildContext, bool) builder;
+ final Duration duration;
+
+ @override
+ State<CopyWrapper> createState() => _CopyWrapperState();
+}
+
+class _CopyWrapperState extends State<CopyWrapper> {
+ bool copied = false;
+
+ void handleCopy() async {
+ if (widget.data == null) return;
+ Clipboard.setData(widget.data!);
+ if (await shouldShowCopied()) {
+ setState(() => copied = true);
+ Future.delayed(widget.duration, () {
+ if (mounted) setState(() => copied = false);
+ });
+ }
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return GestureDetector(
+ behavior: HitTestBehavior.translucent,
+ onTap: handleCopy,
+ child: widget.builder(context, copied),
+ );
+ }
+
+ // android 13 (sdk 33) added a built-in "text was copied to clipboard" ui element
+ Future<bool> shouldShowCopied() async {
+ if (!Platform.isAndroid) return true;
+
+ try {
+ final deviceInfo = DeviceInfoPlugin();
+ final androidInfo = await deviceInfo.androidInfo;
+ final sdk = androidInfo.version.sdkInt;
+
+ return sdk < 33;
+ } catch (_) {
+ return true;
+ }
+ }
+}
diff --git a/lib/src/widgets/new_list_row/list_Item_style_wrapper.dart b/lib/src/widgets/new_list_row/list_Item_style_wrapper.dart
index 9e5926cb..d8a1186c 100644
--- a/lib/src/widgets/new_list_row/list_Item_style_wrapper.dart
+++ b/lib/src/widgets/new_list_row/list_Item_style_wrapper.dart
@@ -6,6 +6,7 @@ class ListItemStyleWrapper extends StatelessWidget {
required this.isFirstInSection,
required this.isLastInSection,
required this.builder,
+ this.backgroundColor,
this.onTap,
this.iconPath,
this.height,
@@ -16,6 +17,7 @@ class ListItemStyleWrapper extends StatelessWidget {
final bool isLastInSection;
final double? height;
final VoidCallback? onTap;
+ final Color? backgroundColor;
final Widget Function(BuildContext context, TextStyle textStyle, TextStyle labelStyle) builder;
@override
@@ -52,7 +54,7 @@ class ListItemStyleWrapper extends StatelessWidget {
shape: RoundedSuperellipseBorder(
borderRadius: radius,
),
- color: theme.colorScheme.surfaceContainer,
+ color: backgroundColor ?? theme.colorScheme.surfaceContainer,
),
child: Material(
color: Colors.transparent,
diff --git a/lib/src/widgets/new_list_row/list_item_regular_row_widget.dart b/lib/src/widgets/new_list_row/list_item_regular_row_widget.dart
index 5343b8c3..47f5cc82 100644
--- a/lib/src/widgets/new_list_row/list_item_regular_row_widget.dart
+++ b/lib/src/widgets/new_list_row/list_item_regular_row_widget.dart
@@ -1,7 +1,9 @@
+import 'package:cake_wallet/generated/i18n.dart';
+import 'package:cake_wallet/new-ui/widgets/copy_wrapper.dart';
import 'package:cake_wallet/src/widgets/cake_image_widget.dart';
import 'package:cake_wallet/src/widgets/new_list_row/list_Item_style_wrapper.dart';
import 'package:flutter/material.dart';
-import 'package:flutter_svg/svg.dart';
+import 'package:flutter/services.dart';
class ListItemRegularRowWidget extends StatelessWidget {
const ListItemRegularRowWidget({
@@ -20,7 +22,8 @@ class ListItemRegularRowWidget extends StatelessWidget {
this.foregroundColor,
this.trailingIconSize,
this.bottomWidget,
- this.trailingWidget
+ this.trailingWidget,
+ this.copyableText
});
final String keyValue;
@@ -38,6 +41,7 @@ class ListItemRegularRowWidget extends StatelessWidget {
final bool truncateTrailingText;
final Color? foregroundColor;
final double? trailingIconSize;
+ final String? copyableText;
@override
Widget build(BuildContext context) {
@@ -47,82 +51,102 @@ class ListItemRegularRowWidget extends StatelessWidget {
? "${trailingText!.substring(0, 17)}..."
: trailingText;
- return ListItemStyleWrapper(
- onTap: onTap,
- iconPath: iconPath,
- isFirstInSection: isFirstInSection,
- isLastInSection: isLastInSection,
- builder: (context, textStyle, labelStyle) {
- return Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- mainAxisAlignment: MainAxisAlignment.center,
- children: [
- Row(
- mainAxisAlignment: MainAxisAlignment.spaceBetween,
+ return CopyWrapper(
+ data: copyableText != null ? ClipboardData(text: copyableText!) : null,
+ builder: (context, copied) => AnimatedSwitcher(
+ duration: Duration(milliseconds: 150),
+ child: ListItemStyleWrapper(
+ key: ValueKey(copied),
+ backgroundColor: copied ? Theme.of(context).colorScheme.surfaceContainerHigh : null,
+ onTap: onTap,
+ iconPath: iconPath,
+ isFirstInSection: isFirstInSection,
+ isLastInSection: isLastInSection,
+ builder: (context, textStyle, labelStyle) {
+ return Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ mainAxisAlignment: MainAxisAlignment.center,
children: [
- Expanded(
- child: Row(
- children: [
- if (iconPath != null)
- Padding(
- padding: const EdgeInsets.only(right: 12.0),
- child: CakeImageWidget(imageUrl: iconPath!, width: 24, height: 24,)
- ),
- Flexible(
- child: Column(
- mainAxisAlignment: MainAxisAlignment.center,
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Text(label,
- style: foregroundColor == null
- ? textStyle
- : textStyle.copyWith(color: foregroundColor)),
- if (subtitle != null)
- Text(
- subtitle!,
- style: labelStyle.copyWith(fontSize: 12),
- ),
- ],
- ),
- ),
- ],
- ),
- ),
Row(
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
- if (trailingTextToShow != null)
- Padding(
- padding: const EdgeInsets.only(right: 8.0),
- child: Text(
- trailingTextToShow,
- style: labelStyle,
- ),
+ Expanded(
+ child: Row(
+ children: [
+ if (iconPath != null)
+ Padding(
+ padding: const EdgeInsets.only(right: 12.0),
+ child: CakeImageWidget(
+ imageUrl: iconPath!,
+ width: 24,
+ height: 24,
+ )),
+ Flexible(
+ child: Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ if (copied)
+ Text(
+ S.of(context).copied,
+ style: textStyle.copyWith(
+ color: Theme.of(context).colorScheme.primary),
+ )
+ else
+ Text(label,
+ style: foregroundColor == null
+ ? textStyle
+ : textStyle.copyWith(color: foregroundColor)),
+ if (subtitle != null)
+ Text(
+ subtitle!,
+ style: labelStyle.copyWith(fontSize: 12),
+ ),
+ ],
+ ),
+ ),
+ ],
),
- if (trailingWidget != null)
- trailingWidget!
- else if (trailingIconPath != null)
- CakeImageWidget(imageUrl:
- trailingIconPath!,
- height: trailingIconSize ?? 18,
- width:trailingIconSize ?? 18,
- colorFilter: ColorFilter.mode(foregroundColor ?? Theme.of(context).colorScheme.onSurfaceVariant,BlendMode.srcIn),
- )
- else if (showArrow)
- Padding(
- padding: const EdgeInsets.symmetric(vertical: 7.0),
- child: CakeImageWidget(imageUrl:
- "assets/new-ui/arrow_forward.svg",
- height: 14,
- color: theme.colorScheme.onSurfaceVariant
+ ),
+ Row(
+ children: [
+ if (trailingTextToShow != null)
+ Padding(
+ padding: const EdgeInsets.only(right: 8.0),
+ child: Text(
+ trailingTextToShow,
+ style: labelStyle,
+ ),
),
- )
+ if (trailingWidget != null)
+ trailingWidget!
+ else if (trailingIconPath != null)
+ CakeImageWidget(
+ imageUrl: trailingIconPath!,
+ height: trailingIconSize ?? 18,
+ width: trailingIconSize ?? 18,
+ colorFilter: ColorFilter.mode(
+ foregroundColor ?? Theme.of(context).colorScheme.onSurfaceVariant,
+ BlendMode.srcIn),
+ )
+ else if (showArrow)
+ Padding(
+ padding: const EdgeInsets.symmetric(vertical: 7.0),
+ child: CakeImageWidget(imageUrl:
+ "assets/new-ui/arrow_forward.svg",
+ height: 14,
+ color: theme.colorScheme.onSurfaceVariant
+ ),
+ )
+ ],
+ ),
],
),
+ if (bottomWidget != null) bottomWidget!
],
- ),
- if (bottomWidget != null) bottomWidget!
- ],
- );
- });
+ );
+ }),
+ ),
+ );
}
}
diff --git a/lib/src/widgets/new_list_row/new_list_section.dart b/lib/src/widgets/new_list_row/new_list_section.dart
index 21893d81..19643612 100644
--- a/lib/src/widgets/new_list_row/new_list_section.dart
+++ b/lib/src/widgets/new_list_row/new_list_section.dart
@@ -103,6 +103,7 @@ class NewListSections extends StatelessWidget {
showArrow: item.showArrow,
truncateTrailingText: item.truncateTrailingText,
foregroundColor: item.foregroundColor,
+ copyableText: item.copyableText,
trailingIconSize: item.trailingIconSize,
trailingWidget: item.trailingWidget,
bottomWidget: item.bottomWidget,
Why this scored 15/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.