add copy indicator on tx details screen (#3152)

malik1004x committed Apr 14, 2026 at 05:45 UTC c338c6d6b8c5bc0f64e2044e490b068a5f969d86
6 files changed +162 -77
lib/entities/new_ui_entities/list_item/list_item_regular_row.dart
+3 -1
@@ -15,13 +15,15 @@ class ListItemRegularRow extends ListItem {
15 this.trailingWidget,
16 this.truncateTrailingText = false,
17 this.foregroundColor,
18 - this.trailingIconSize
18 + this.trailingIconSize,
19 + this.copyableText
20 });
21
22 final String? subtitle;
23 final String? trailingText;
24 final String? iconPath;
25 final String? trailingIconPath;
26 + final String? copyableText;
27 final VoidCallback? onTap;
28 final bool showArrow;
29 final Widget? bottomWidget;
lib/new-ui/widgets/coins_page/assets_history/transaction_details_modal.dart
+1 -4
@@ -99,10 +99,7 @@ class _TransactionDetailsModalState extends State<TransactionDetailsModal> {
99 item.value.length > 25;
100
101 return ListItemRegularRow(
102 - onTap: () {
103 - Clipboard.setData(
104 - ClipboardData(text: item.value));
105 - },
102 + copyableText: item.value,
103 showArrow: false,
104 keyValue:
105 ((item.key as ValueKey?)?.value as String?) ??
lib/new-ui/widgets/copy_wrapper.dart new
+59
@@ -0,0 +1,59 @@
1 +import 'dart:io';
2 +
3 +import 'package:device_info_plus/device_info_plus.dart';
4 +import 'package:flutter/material.dart';
5 +import 'package:flutter/services.dart';
6 +
7 +class CopyWrapper extends StatefulWidget {
8 + const CopyWrapper(
9 + {super.key,
10 + this.data,
11 + required this.builder,
12 + this.duration = const Duration(milliseconds: 1200)});
13 +
14 + final ClipboardData? data;
15 + final Widget Function(BuildContext, bool) builder;
16 + final Duration duration;
17 +
18 + @override
19 + State<CopyWrapper> createState() => _CopyWrapperState();
20 +}
21 +
22 +class _CopyWrapperState extends State<CopyWrapper> {
23 + bool copied = false;
24 +
25 + void handleCopy() async {
26 + if (widget.data == null) return;
27 + Clipboard.setData(widget.data!);
28 + if (await shouldShowCopied()) {
29 + setState(() => copied = true);
30 + Future.delayed(widget.duration, () {
31 + if (mounted) setState(() => copied = false);
32 + });
33 + }
34 + }
35 +
36 + @override
37 + Widget build(BuildContext context) {
38 + return GestureDetector(
39 + behavior: HitTestBehavior.translucent,
40 + onTap: handleCopy,
41 + child: widget.builder(context, copied),
42 + );
43 + }
44 +
45 + // android 13 (sdk 33) added a built-in "text was copied to clipboard" ui element
46 + Future<bool> shouldShowCopied() async {
47 + if (!Platform.isAndroid) return true;
48 +
49 + try {
50 + final deviceInfo = DeviceInfoPlugin();
51 + final androidInfo = await deviceInfo.androidInfo;
52 + final sdk = androidInfo.version.sdkInt;
53 +
54 + return sdk < 33;
55 + } catch (_) {
56 + return true;
57 + }
58 + }
59 +}
lib/src/widgets/new_list_row/list_Item_style_wrapper.dart
+3 -1
@@ -6,6 +6,7 @@ class ListItemStyleWrapper extends StatelessWidget {
6 required this.isFirstInSection,
7 required this.isLastInSection,
8 required this.builder,
9 + this.backgroundColor,
10 this.onTap,
11 this.iconPath,
12 this.height,
@@ -16,6 +17,7 @@ class ListItemStyleWrapper extends StatelessWidget {
17 final bool isLastInSection;
18 final double? height;
19 final VoidCallback? onTap;
20 + final Color? backgroundColor;
21 final Widget Function(BuildContext context, TextStyle textStyle, TextStyle labelStyle) builder;
22
23 @override
@@ -52,7 +54,7 @@ class ListItemStyleWrapper extends StatelessWidget {
54 shape: RoundedSuperellipseBorder(
55 borderRadius: radius,
56 ),
55 - color: theme.colorScheme.surfaceContainer,
57 + color: backgroundColor ?? theme.colorScheme.surfaceContainer,
58 ),
59 child: Material(
60 color: Colors.transparent,
lib/src/widgets/new_list_row/list_item_regular_row_widget.dart
+95 -71
@@ -1,7 +1,9 @@
1 +import 'package:cake_wallet/generated/i18n.dart';
2 +import 'package:cake_wallet/new-ui/widgets/copy_wrapper.dart';
3 import 'package:cake_wallet/src/widgets/cake_image_widget.dart';
4 import 'package:cake_wallet/src/widgets/new_list_row/list_Item_style_wrapper.dart';
5 import 'package:flutter/material.dart';
4 -import 'package:flutter_svg/svg.dart';
6 +import 'package:flutter/services.dart';
7
8 class ListItemRegularRowWidget extends StatelessWidget {
9 const ListItemRegularRowWidget({
@@ -20,7 +22,8 @@ class ListItemRegularRowWidget extends StatelessWidget {
22 this.foregroundColor,
23 this.trailingIconSize,
24 this.bottomWidget,
23 - this.trailingWidget
25 + this.trailingWidget,
26 + this.copyableText
27 });
28
29 final String keyValue;
@@ -38,6 +41,7 @@ class ListItemRegularRowWidget extends StatelessWidget {
41 final bool truncateTrailingText;
42 final Color? foregroundColor;
43 final double? trailingIconSize;
44 + final String? copyableText;
45
46 @override
47 Widget build(BuildContext context) {
@@ -47,82 +51,102 @@ class ListItemRegularRowWidget extends StatelessWidget {
51 ? "${trailingText!.substring(0, 17)}..."
52 : trailingText;
53
50 - return ListItemStyleWrapper(
51 - onTap: onTap,
52 - iconPath: iconPath,
53 - isFirstInSection: isFirstInSection,
54 - isLastInSection: isLastInSection,
55 - builder: (context, textStyle, labelStyle) {
56 - return Column(
57 - crossAxisAlignment: CrossAxisAlignment.start,
58 - mainAxisAlignment: MainAxisAlignment.center,
59 - children: [
60 - Row(
61 - mainAxisAlignment: MainAxisAlignment.spaceBetween,
54 + return CopyWrapper(
55 + data: copyableText != null ? ClipboardData(text: copyableText!) : null,
56 + builder: (context, copied) => AnimatedSwitcher(
57 + duration: Duration(milliseconds: 150),
58 + child: ListItemStyleWrapper(
59 + key: ValueKey(copied),
60 + backgroundColor: copied ? Theme.of(context).colorScheme.surfaceContainerHigh : null,
61 + onTap: onTap,
62 + iconPath: iconPath,
63 + isFirstInSection: isFirstInSection,
64 + isLastInSection: isLastInSection,
65 + builder: (context, textStyle, labelStyle) {
66 + return Column(
67 + crossAxisAlignment: CrossAxisAlignment.start,
68 + mainAxisAlignment: MainAxisAlignment.center,
69 children: [
63 - Expanded(
64 - child: Row(
65 - children: [
66 - if (iconPath != null)
67 - Padding(
68 - padding: const EdgeInsets.only(right: 12.0),
69 - child: CakeImageWidget(imageUrl: iconPath!, width: 24, height: 24,)
70 - ),
71 - Flexible(
72 - child: Column(
73 - mainAxisAlignment: MainAxisAlignment.center,
74 - crossAxisAlignment: CrossAxisAlignment.start,
75 - children: [
76 - Text(label,
77 - style: foregroundColor == null
78 - ? textStyle
79 - : textStyle.copyWith(color: foregroundColor)),
80 - if (subtitle != null)
81 - Text(
82 - subtitle!,
83 - style: labelStyle.copyWith(fontSize: 12),
84 - ),
85 - ],
86 - ),
87 - ),
88 - ],
89 - ),
90 - ),
70 Row(
71 + mainAxisAlignment: MainAxisAlignment.spaceBetween,
72 children: [
93 - if (trailingTextToShow != null)
94 - Padding(
95 - padding: const EdgeInsets.only(right: 8.0),
96 - child: Text(
97 - trailingTextToShow,
98 - style: labelStyle,
99 - ),
73 + Expanded(
74 + child: Row(
75 + children: [
76 + if (iconPath != null)
77 + Padding(
78 + padding: const EdgeInsets.only(right: 12.0),
79 + child: CakeImageWidget(
80 + imageUrl: iconPath!,
81 + width: 24,
82 + height: 24,
83 + )),
84 + Flexible(
85 + child: Column(
86 + mainAxisAlignment: MainAxisAlignment.center,
87 + crossAxisAlignment: CrossAxisAlignment.start,
88 + children: [
89 + if (copied)
90 + Text(
91 + S.of(context).copied,
92 + style: textStyle.copyWith(
93 + color: Theme.of(context).colorScheme.primary),
94 + )
95 + else
96 + Text(label,
97 + style: foregroundColor == null
98 + ? textStyle
99 + : textStyle.copyWith(color: foregroundColor)),
100 + if (subtitle != null)
101 + Text(
102 + subtitle!,
103 + style: labelStyle.copyWith(fontSize: 12),
104 + ),
105 + ],
106 + ),
107 + ),
108 + ],
109 ),
101 - if (trailingWidget != null)
102 - trailingWidget!
103 - else if (trailingIconPath != null)
104 - CakeImageWidget(imageUrl:
105 - trailingIconPath!,
106 - height: trailingIconSize ?? 18,
107 - width:trailingIconSize ?? 18,
108 - colorFilter: ColorFilter.mode(foregroundColor ?? Theme.of(context).colorScheme.onSurfaceVariant,BlendMode.srcIn),
109 - )
110 - else if (showArrow)
111 - Padding(
112 - padding: const EdgeInsets.symmetric(vertical: 7.0),
113 - child: CakeImageWidget(imageUrl:
114 - "assets/new-ui/arrow_forward.svg",
115 - height: 14,
116 - color: theme.colorScheme.onSurfaceVariant
110 + ),
111 + Row(
112 + children: [
113 + if (trailingTextToShow != null)
114 + Padding(
115 + padding: const EdgeInsets.only(right: 8.0),
116 + child: Text(
117 + trailingTextToShow,
118 + style: labelStyle,
119 + ),
120 ),
118 - )
121 + if (trailingWidget != null)
122 + trailingWidget!
123 + else if (trailingIconPath != null)
124 + CakeImageWidget(
125 + imageUrl: trailingIconPath!,
126 + height: trailingIconSize ?? 18,
127 + width: trailingIconSize ?? 18,
128 + colorFilter: ColorFilter.mode(
129 + foregroundColor ?? Theme.of(context).colorScheme.onSurfaceVariant,
130 + BlendMode.srcIn),
131 + )
132 + else if (showArrow)
133 + Padding(
134 + padding: const EdgeInsets.symmetric(vertical: 7.0),
135 + child: CakeImageWidget(imageUrl:
136 + "assets/new-ui/arrow_forward.svg",
137 + height: 14,
138 + color: theme.colorScheme.onSurfaceVariant
139 + ),
140 + )
141 + ],
142 + ),
143 ],
144 ),
145 + if (bottomWidget != null) bottomWidget!
146 ],
122 - ),
123 - if (bottomWidget != null) bottomWidget!
124 - ],
125 - );
126 - });
147 + );
148 + }),
149 + ),
150 + );
151 }
152 }
lib/src/widgets/new_list_row/new_list_section.dart
+1
@@ -103,6 +103,7 @@ class NewListSections extends StatelessWidget {
103 showArrow: item.showArrow,
104 truncateTrailingText: item.truncateTrailingText,
105 foregroundColor: item.foregroundColor,
106 + copyableText: item.copyableText,
107 trailingIconSize: item.trailingIconSize,
108 trailingWidget: item.trailingWidget,
109 bottomWidget: item.bottomWidget,