dev
dart 115 lines 3.93 KB
Raw
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 }