| 1 | import 'dart:io'; |
| 2 | |
| 3 | import 'package:cake_wallet/generated/i18n.dart'; |
| 4 | import 'package:cake_wallet/utils/clipboard_util.dart'; |
| 5 | import 'package:device_info_plus/device_info_plus.dart'; |
| 6 | import 'package:flutter/material.dart'; |
| 7 | import 'package:flutter/semantics.dart'; |
| 8 | import 'package:flutter/services.dart'; |
| 9 | |
| 10 | class CopyWrapper extends StatefulWidget { |
| 11 | const CopyWrapper( |
| 12 | {super.key, |
| 13 | this.data, |
| 14 | this.requireLongPress = false, |
| 15 | this.isSensitive = false, |
| 16 | this.builder, |
| 17 | this.controlBuilder, |
| 18 | this.duration = const Duration(milliseconds: 1200)}) |
| 19 | : assert((builder == null) != (controlBuilder == null), |
| 20 | "Provide exactly one of builder or controlBuilder"); |
| 21 | |
| 22 | final ClipboardData? data; |
| 23 | final bool isSensitive; |
| 24 | final bool requireLongPress; |
| 25 | |
| 26 | /// Builds content that this widget makes copyable with its own gesture |
| 27 | /// detector. Receives the transient "copied" state. |
| 28 | final Widget Function(BuildContext, bool)? builder; |
| 29 | |
| 30 | /// Builds a control that performs the copy itself, so no extra gesture |
| 31 | /// detector or actionable semantics node is stacked on top of it. [onCopy] is |
| 32 | /// null when there is nothing to copy. |
| 33 | final Widget Function(BuildContext context, bool copied, VoidCallback? onCopy)? controlBuilder; |
| 34 | |
| 35 | final Duration duration; |
| 36 | |
| 37 | @override |
| 38 | State<CopyWrapper> createState() => _CopyWrapperState(); |
| 39 | } |
| 40 | |
| 41 | class _CopyWrapperState extends State<CopyWrapper> { |
| 42 | bool copied = false; |
| 43 | |
| 44 | void handleCopy() async { |
| 45 | if (widget.data == null) return; |
| 46 | ClipboardUtil.setSensitiveDataToClipboard(widget.data!, isSensitive: widget.isSensitive); |
| 47 | HapticFeedback.mediumImpact(); |
| 48 | // Screen readers learn about the copy from the transient "copied" state |
| 49 | // below, which build() marks as a live region, instead of from a push |
| 50 | // announcement: those are deprecated on Android and make TalkBack drop |
| 51 | // whatever it was saying. Where we don't render that state, android 13+ |
| 52 | // already shows its own clipboard confirmation. |
| 53 | if (await shouldShowCopied()) { |
| 54 | setState(() => copied = true); |
| 55 | Future.delayed(widget.duration, () { |
| 56 | if (mounted) setState(() => copied = false); |
| 57 | }); |
| 58 | } |
| 59 | } |
| 60 | |
| 61 | @override |
| 62 | Widget build(BuildContext context) { |
| 63 | final VoidCallback? onCopy = widget.data == null ? null : handleCopy; |
| 64 | |
| 65 | // The control's own label carries the copied state (Copy -> Copied), so |
| 66 | // merging it into one node and marking that node a live region while copied |
| 67 | // is enough for a screen reader to pick the change up. |
| 68 | if (widget.controlBuilder != null) { |
| 69 | return MergeSemantics( |
| 70 | child: Semantics( |
| 71 | liveRegion: copied, |
| 72 | child: widget.controlBuilder!(context, copied, onCopy), |
| 73 | ), |
| 74 | ); |
| 75 | } |
| 76 | |
| 77 | final content = widget.builder!(context, copied); |
| 78 | |
| 79 | // Nothing to copy: don't leave behind a gesture detector that a screen |
| 80 | // reader would present as an actionable target that does nothing. |
| 81 | if (onCopy == null) return content; |
| 82 | |
| 83 | return MergeSemantics( |
| 84 | child: Semantics( |
| 85 | button: !widget.requireLongPress, |
| 86 | liveRegion: copied, |
| 87 | hint: widget.requireLongPress ? S.of(context).long_press_to_copy : S.of(context).copy, |
| 88 | customSemanticsActions: <CustomSemanticsAction, VoidCallback>{ |
| 89 | CustomSemanticsAction(label: S.of(context).copy): onCopy, |
| 90 | }, |
| 91 | child: GestureDetector( |
| 92 | behavior: HitTestBehavior.translucent, |
| 93 | onTap: widget.requireLongPress ? null : onCopy, |
| 94 | onLongPress: !widget.requireLongPress ? null : onCopy, |
| 95 | child: content, |
| 96 | ), |
| 97 | ), |
| 98 | ); |
| 99 | } |
| 100 | |
| 101 | // android 13 (sdk 33) added a built-in "text was copied to clipboard" ui element |
| 102 | Future<bool> shouldShowCopied() async { |
| 103 | if (!Platform.isAndroid) return true; |
| 104 | |
| 105 | try { |
| 106 | final deviceInfo = DeviceInfoPlugin(); |
| 107 | final androidInfo = await deviceInfo.androidInfo; |
| 108 | final sdk = androidInfo.version.sdkInt; |
| 109 | |
| 110 | return sdk < 33; |
| 111 | } catch (_) { |
| 112 | return true; |
| 113 | } |
| 114 | } |
| 115 | } |