a11y: decorative/meaningful image semantics, labeled icon buttons, audible addresses (#3465)
Seth For Privacy committed
Aug 20, 2026 at 10:40 UTC
c9974673c1f812e740f3a1a475a08a008b2a8ba1
12 files changed
+234
-108
lib/new-ui/pages/scan_page.dart
+1
@@ -268,6 +268,7 @@ class _ScanPageState extends State<ScanPage> {
268
),
269
FloatingIconButton(
270
iconPath: "assets/new-ui/paste.svg",
271
+ semanticLabel: S.of(context).paste,
272
onPressed: () async {
273
final data = await Clipboard.getData("text/plain");
274
if (data?.text != null) {
lib/new-ui/widgets/coins_page/token_image_widget.dart
+6
@@ -17,12 +17,17 @@ class TokenImageWidget extends StatefulWidget {
17
required this.imageUrl,
18
required this.size,
19
this.errorWidget,
20
+ this.semanticsLabel,
21
});
22
23
final String imageUrl;
24
final double size;
25
final Widget? errorWidget;
26
27
+ /// Accessible name for the token artwork. Leave `null` (the default) when the
28
+ /// token is already named by adjacent text, so the image stays decorative.
29
+ final String? semanticsLabel;
30
+
31
@override
32
State<TokenImageWidget> createState() => _TokenImageWidgetState();
33
}
@@ -132,6 +137,7 @@ class _TokenImageWidgetState extends State<TokenImageWidget> {
137
fit: BoxFit.cover,
138
filterQuality: FilterQuality.high,
139
errorWidget: widget.errorWidget,
140
+ semanticsLabel: widget.semanticsLabel,
141
),
142
);
143
lib/new-ui/widgets/receive_page/receive_qr_code.dart
+93
-74
@@ -51,85 +51,104 @@ class ReceiveQrCode extends StatelessWidget {
51
height: 45,
52
)),
53
),
54
- GestureDetector(
55
- onTap: onTap,
56
- behavior: HitTestBehavior.opaque,
57
- child: TweenAnimationBuilder<double>(
58
- tween: Tween<double>(begin: 0, end: targetY),
59
- duration: animDuration,
60
- curve: Curves.easeOutCubic,
61
- builder: (context, value, child) {
62
- return Transform.translate(
63
- offset: Offset(0, value),
64
- child: child,
65
- );
66
- },
67
- child: Observer(
68
- builder: (_) => Column(
69
- children: [
70
- AnimatedScale(
71
- curve: Curves.easeOutCubic,
72
- alignment: Alignment.bottomCenter,
73
- scale: resolvedScale,
74
- duration: animDuration,
75
- child: AnimatedContainer(
76
- duration: animDuration,
54
+ // The QR, and the payjoin badge below it, share one tap target, so they
55
+ // are merged into a single button node named by the QR's own label
56
+ // rather than each becoming a separate focus stop.
57
+ MergeSemantics(
58
+ child: Semantics(
59
+ hint: largeQrMode ? S.of(context).qr_close_fullscreen : S.of(context).qr_fullscreen,
60
+ button: true,
61
+ enabled: true,
62
+ child: GestureDetector(
63
+ onTap: onTap,
64
+ behavior: HitTestBehavior.opaque,
65
+ child: TweenAnimationBuilder<double>(
66
+ tween: Tween<double>(begin: 0, end: targetY),
67
+ duration: animDuration,
68
+ curve: Curves.easeOutCubic,
69
+ builder: (context, value, child) {
70
+ return Transform.translate(
71
+ offset: Offset(0, value),
72
+ child: child,
73
+ );
74
+ },
75
+ child: Observer(
76
+ builder: (_) => Column(
77
+ children: [
78
+ AnimatedScale(
79
curve: Curves.easeOutCubic,
78
- width: resolvedSize,
79
- height: resolvedSize,
80
- decoration: BoxDecoration(
81
- borderRadius: BorderRadius.circular(20),
82
- color: Colors.white,
83
- ),
84
- child: Container(
80
+ alignment: Alignment.bottomCenter,
81
+ scale: resolvedScale,
82
+ duration: animDuration,
83
+ child: AnimatedContainer(
84
+ duration: animDuration,
85
+ curve: Curves.easeOutCubic,
86
+ width: resolvedSize,
87
+ height: resolvedSize,
88
decoration: BoxDecoration(
86
- borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
87
- color: addressListViewModel.hasPayjoin && !largeQrMode
88
- ? Theme.of(context).colorScheme.surfaceContainer
89
- : Colors.transparent),
90
- child: ClipRRect(
91
- borderRadius: BorderRadius.circular(20),
92
- child: Observer(
93
- builder: (_) => QrImage(
94
- data: addressListViewModel.uri.toString(),
95
- size: resolvedSize,
96
- embeddedImagePath:
97
- addressListViewModel.tokenCurrency != null
98
- ? addressListViewModel.tokenCurrency ==
99
- CryptoCurrency.btcln
100
- ? addressListViewModel.qrImage
101
- : addressListViewModel.tokenCurrency!.iconPath
102
- : addressListViewModel.qrImage,
103
- ))),
89
+ borderRadius: BorderRadius.circular(20),
90
+ color: Colors.white,
91
+ ),
92
+ child: Container(
93
+ decoration: BoxDecoration(
94
+ borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
95
+ color: addressListViewModel.hasPayjoin && !largeQrMode
96
+ ? Theme.of(context).colorScheme.surfaceContainer
97
+ : Colors.transparent),
98
+ child: ClipRRect(
99
+ borderRadius: BorderRadius.circular(20),
100
+ child: Observer(
101
+ builder: (_) => QrImage(
102
+ data: addressListViewModel.uri.toString(),
103
+ size: resolvedSize,
104
+ semanticsLabel: S.of(context).qr_code_receive_address,
105
+ embeddedImagePath:
106
+ addressListViewModel.tokenCurrency != null
107
+ ? addressListViewModel.tokenCurrency ==
108
+ CryptoCurrency.btcln
109
+ ? addressListViewModel.qrImage
110
+ : addressListViewModel
111
+ .tokenCurrency!.iconPath
112
+ : addressListViewModel.qrImage,
113
+ ))),
114
+ ),
115
),
116
),
106
- ),
107
- if (addressListViewModel.hasPayjoin)
108
- Opacity(
109
- opacity: largeQrMode ? 0 : 1,
110
- child: Container(
111
- width: resolvedSize,
112
- decoration: BoxDecoration(
113
- borderRadius: BorderRadius.vertical(bottom: Radius.circular(16)),
114
- color: Theme.of(context).colorScheme.surfaceContainer),
115
- child: Padding(
116
- padding: const EdgeInsets.symmetric(vertical: 2.0),
117
- child: Row(
118
- mainAxisAlignment: MainAxisAlignment.center,
119
- spacing: 4,
120
- children: [
121
- CakeImageWidget(imageUrl: "assets/new-ui/payjoin.svg"),
122
- Text(S.of(context).payjoin_enabled)
123
- ],
124
- ),
125
- ))),
126
- AnimatedSize(
127
- duration: animDuration,
128
- curve: Curves.easeOutCubic,
129
- child: SizedBox(height: largeQrMode ? largeQrModeBottomPadding + 40 : 0))
130
- ],
117
+ if (addressListViewModel.hasPayjoin)
118
+ // Hidden but still mounted in large-QR mode, so keep it
119
+ // out of the semantics tree there.
120
+ ExcludeSemantics(
121
+ excluding: largeQrMode,
122
+ child: Opacity(
123
+ opacity: largeQrMode ? 0 : 1,
124
+ child: Container(
125
+ width: resolvedSize,
126
+ decoration: BoxDecoration(
127
+ borderRadius:
128
+ BorderRadius.vertical(bottom: Radius.circular(16)),
129
+ color: Theme.of(context).colorScheme.surfaceContainer),
130
+ child: Padding(
131
+ padding: const EdgeInsets.symmetric(vertical: 2.0),
132
+ child: Row(
133
+ mainAxisAlignment: MainAxisAlignment.center,
134
+ spacing: 4,
135
+ children: [
136
+ CakeImageWidget(imageUrl: "assets/new-ui/payjoin.svg"),
137
+ Text(S.of(context).payjoin_enabled)
138
+ ],
139
+ ),
140
+ ))),
141
+ ),
142
+ AnimatedSize(
143
+ duration: animDuration,
144
+ curve: Curves.easeOutCubic,
145
+ child: SizedBox(height: largeQrMode ? largeQrModeBottomPadding + 40 : 0))
146
+ ],
147
+ ),
148
),
132
- )),
149
+ ),
150
+ ),
151
+ ),
152
),
153
],
154
);
lib/new-ui/widgets/send_page/floating_icon_button.dart
+32
-15
@@ -3,28 +3,45 @@ import 'package:flutter/material.dart';
3
import 'package:flutter_svg/svg.dart';
4
5
class FloatingIconButton extends StatelessWidget {
6
- const FloatingIconButton({super.key, required this.iconPath, required this.onPressed});
6
+ const FloatingIconButton({
7
+ super.key,
8
+ required this.iconPath,
9
+ required this.onPressed,
10
+ required this.semanticLabel,
11
+ });
12
13
final String iconPath;
14
final VoidCallback onPressed;
15
16
+ /// Localized accessible name for this icon-only button. The icon itself stays
17
+ /// decorative, so this is the only name a screen reader can announce.
18
+ final String semanticLabel;
19
+
20
@override
21
Widget build(BuildContext context) {
13
- return Material(
14
- color: Colors.transparent,
15
- borderRadius: const BorderRadius.all(Radius.circular(6)),
16
- child: InkWell(
22
+ return MergeSemantics(
23
+ child: Semantics(
24
+ label: semanticLabel,
25
+ button: true,
26
+ enabled: true,
27
+ child: Material(
28
+ color: Colors.transparent,
29
borderRadius: const BorderRadius.all(Radius.circular(6)),
18
- onTap: onPressed,
19
- child: Padding(
20
- padding: const EdgeInsets.all(4.0),
21
- child: CakeImageWidget(
22
- imageUrl: iconPath,
23
- width: 22,
24
- height: 22,
25
- colorFilter: ColorFilter.mode(Theme.of(context).colorScheme.primary, BlendMode.srcIn),
26
- ),
27
- )),
30
+ child: InkWell(
31
+ borderRadius: const BorderRadius.all(Radius.circular(6)),
32
+ onTap: onPressed,
33
+ child: Padding(
34
+ padding: const EdgeInsets.all(4.0),
35
+ child: CakeImageWidget(
36
+ imageUrl: iconPath,
37
+ width: 22,
38
+ height: 22,
39
+ colorFilter:
40
+ ColorFilter.mode(Theme.of(context).colorScheme.primary, BlendMode.srcIn),
41
+ ),
42
+ )),
43
+ ),
44
+ ),
45
);
46
}
47
}
lib/new-ui/widgets/send_page/send_address_input.dart
+3
@@ -137,16 +137,19 @@ class _NewSendAddressInputState extends State<NewSendAddressInput> {
137
SizedBox.shrink(),
138
FloatingIconButton(
139
iconPath: "assets/new-ui/paste.svg",
140
+ semanticLabel: S.of(context).paste,
141
onPressed: () async {
142
_pasteAddress(context);
143
}),
144
FloatingIconButton(
145
iconPath: "assets/new-ui/scan.svg",
146
+ semanticLabel: S.of(context).scan_qr_code,
147
onPressed: () {
148
_presentQRScanner(context);
149
}),
150
FloatingIconButton(
151
iconPath: "assets/new-ui/contacts_outlined.svg",
152
+ semanticLabel: S.of(context).address_book,
153
onPressed: () {
154
_presetAddressBookPicker(context);
155
}),
lib/new-ui/widgets/send_page/send_amount_input.dart
+1
@@ -97,6 +97,7 @@ class _NewSendAmountInputState extends State<NewSendAmountInput> {
97
),
98
FloatingIconButton(
99
iconPath: "assets/new-ui/paste.svg",
100
+ semanticLabel: S.of(context).paste,
101
onPressed: () async {
102
final data = await Clipboard.getData(Clipboard.kTextPlain);
103
if (data != null && data.text != null) {
lib/new-ui/widgets/send_page/send_memo_input.dart
+1
@@ -46,6 +46,7 @@ class NewSendMemoInput extends StatelessWidget {
46
SizedBox(width: 12),
47
FloatingIconButton(
48
iconPath: "assets/new-ui/paste.svg",
49
+ semanticLabel: S.of(context).paste,
50
onPressed: () async {
51
final data = await Clipboard.getData(Clipboard.kTextPlain);
52
if (data != null && data.text != null) {
lib/src/screens/receive/widgets/qr_image.dart
+13
-1
@@ -1,3 +1,4 @@
1
+import 'package:cake_wallet/generated/i18n.dart';
2
import 'package:cake_wallet/new-ui/widgets/coins_page/token_image_widget.dart';
3
import 'package:cake_wallet/src/widgets/cake_image_widget.dart';
4
import 'package:flutter/material.dart';
@@ -13,6 +14,7 @@ class QrImage extends StatelessWidget {
14
this.errorCorrectionLevel = qr.QrErrorCorrectLevel.H,
15
this.embeddedImagePath,
16
this.badgeImageOnEmbeddedImagePath,
17
+ this.semanticsLabel,
18
});
19
20
final double? size;
@@ -24,6 +26,11 @@ class QrImage extends StatelessWidget {
26
final String? embeddedImagePath;
27
final String? badgeImageOnEmbeddedImagePath;
28
29
+ /// Accessible name announced for the code itself. Defaults to a generic
30
+ /// localized "QR code" so the code is always discoverable; pass a more
31
+ /// specific label when the host screen knows what the code encodes.
32
+ final String? semanticsLabel;
33
+
34
@override
35
Widget build(BuildContext context) {
36
final imagePath = embeddedImagePath ?? 'assets/images/qr-cake.png';
@@ -68,8 +75,13 @@ class QrImage extends StatelessWidget {
75
foregroundColor: foregroundColor,
76
backgroundColor: backgroundColor,
77
padding: const EdgeInsets.all(12.0),
78
+ // The package default is a hardcoded English "qr code"; use the
79
+ // localized string so the code is announced in the user's language.
80
+ semanticsLabel: semanticsLabel ?? S.of(context).scan_qr_code,
81
),
72
- centerImageToUse,
82
+ // The embedded logo and its badge are purely decorative — the node
83
+ // above already announces the code — so keep them out of the tree.
84
+ ExcludeSemantics(child: centerImageToUse),
85
],
86
);
87
}
lib/src/widgets/cake_image_widget.dart
+44
-5
@@ -17,6 +17,7 @@ class CakeImageWidget extends StatelessWidget {
17
this.alignment,
18
this.allowDrawingOutsideViewBox,
19
this.filterQuality,
20
+ this.semanticsLabel,
21
});
22
23
final String? imageUrl;
@@ -32,6 +33,16 @@ class CakeImageWidget extends StatelessWidget {
33
final double borderRadius;
34
final FilterQuality? filterQuality;
35
36
+ /// Accessible name for this image.
37
+ ///
38
+ /// Leave `null` (the default) for decorative imagery — the image then
39
+ /// contributes nothing at all to the semantics tree, so screen readers do not
40
+ /// stop on an unnamed node. Pass a localized string only when the image is the
41
+ /// sole carrier of information for the user.
42
+ final String? semanticsLabel;
43
+
44
+ bool get _isDecorative => semanticsLabel == null;
45
+
46
@override
47
Widget build(BuildContext context) {
48
if (imageUrl == null || imageUrl!.isEmpty) {
@@ -52,6 +63,8 @@ class CakeImageWidget extends StatelessWidget {
63
alignment: alignment ?? Alignment.center,
64
allowDrawingOutsideViewBox: allowDrawingOutsideViewBox ?? false,
65
colorFilter: effectiveColorFilter,
66
+ semanticsLabel: semanticsLabel,
67
+ excludeFromSemantics: _isDecorative,
68
fit: fit ?? BoxFit.contain, errorBuilder: (context, e, trace) {
69
return SvgPicture.asset(
70
imageUrl!,
@@ -61,6 +74,8 @@ class CakeImageWidget extends StatelessWidget {
74
width: width,
75
errorBuilder: (_, __, ___) => SizedBox(height: height, width: width),
76
colorFilter: effectiveColorFilter,
77
+ semanticsLabel: semanticsLabel,
78
+ excludeFromSemantics: _isDecorative,
79
fit: fit ?? BoxFit.contain,
80
);
81
});
@@ -72,6 +87,8 @@ class CakeImageWidget extends StatelessWidget {
87
fit: fit,
88
color: color,
89
filterQuality: filterQuality ?? FilterQuality.medium,
90
+ semanticLabel: semanticsLabel,
91
+ excludeFromSemantics: _isDecorative,
92
errorBuilder: (_, __, ___) => _buildErrorWidget(context),
93
);
94
}
@@ -85,9 +102,9 @@ class CakeImageWidget extends StatelessWidget {
102
alignment: alignment ?? Alignment.center,
103
allowDrawingOutsideViewBox: allowDrawingOutsideViewBox ?? false,
104
fit: fit ?? BoxFit.contain,
88
- placeholderBuilder: (_) {
89
- return loadingWidget ?? const Center(child: CircularProgressIndicator());
90
- },
105
+ semanticsLabel: semanticsLabel,
106
+ excludeFromSemantics: _isDecorative,
107
+ placeholderBuilder: (_) => _buildLoadingWidget(),
108
errorBuilder: (_, __, ___) => _buildErrorWidget(context),
109
)
110
: Image.network(
@@ -97,9 +114,11 @@ class CakeImageWidget extends StatelessWidget {
114
fit: fit ?? BoxFit.cover,
115
color: color,
116
filterQuality: filterQuality ?? FilterQuality.medium,
117
+ semanticLabel: semanticsLabel,
118
+ excludeFromSemantics: _isDecorative,
119
loadingBuilder: (_, Widget child, ImageChunkEvent? progress) {
120
if (progress == null) return child;
102
- return loadingWidget ?? const Center(child: CircularProgressIndicator());
121
+ return _buildLoadingWidget();
122
},
123
errorBuilder: (_, __, ___) => _buildErrorWidget(context),
124
);
@@ -108,8 +127,13 @@ class CakeImageWidget extends StatelessWidget {
127
return imageWidget;
128
}
129
130
+ /// A caller-supplied [loadingWidget] owns its own semantics; the built-in
131
+ /// spinner is purely visual and must not become an unnamed focus stop.
132
+ Widget _buildLoadingWidget() =>
133
+ loadingWidget ?? const ExcludeSemantics(child: Center(child: CircularProgressIndicator()));
134
+
135
Widget _buildErrorWidget(BuildContext context) {
112
- return Container(
136
+ final Widget placeholder = Container(
137
height: height,
138
width: width,
139
decoration: BoxDecoration(
@@ -125,5 +149,20 @@ class CakeImageWidget extends StatelessWidget {
149
),
150
),
151
);
152
+
153
+ // Several call sites render meaningful content (e.g. the asset's initials) as
154
+ // their [errorWidget], so leave its semantics to the caller.
155
+ if (errorWidget != null) {
156
+ return placeholder;
157
+ }
158
+
159
+ return _isDecorative
160
+ ? ExcludeSemantics(child: placeholder)
161
+ : Semantics(
162
+ container: true,
163
+ image: true,
164
+ label: semanticsLabel,
165
+ child: ExcludeSemantics(child: placeholder),
166
+ );
167
}
168
}
lib/src/widgets/rounded_checkbox.dart
+7
-1
@@ -9,7 +9,9 @@ class RoundedCheckbox extends StatelessWidget {
9
10
@override
11
Widget build(BuildContext context) {
12
- return value
12
+ // Unchecked renders nothing at all, so without an explicit `checked` state
13
+ // the selection is invisible to screen readers.
14
+ final Widget indicator = value
15
? Container(
16
height: 20.0,
17
width: 20.0,
@@ -23,5 +25,9 @@ class RoundedCheckbox extends StatelessWidget {
25
size: 14.0,
26
))
27
: Offstage();
28
+
29
+ // Deliberately not a semantics container: the state merges into the
30
+ // enclosing row/option node instead of adding a second focus stop.
31
+ return Semantics(checked: value, child: indicator);
32
}
33
}
lib/utils/address_formatter.dart
+31
-12
@@ -80,13 +80,26 @@ class AddressFormatter {
80
spans.add(TextSpan(text: '${chunks[i]} ', style: style));
81
}
82
83
- return RichText(
84
- text: TextSpan(children: spans),
85
- textAlign: textAlign ?? TextAlign.start,
86
- overflow: TextOverflow.visible,
83
+ return _withAddressSemantics(
84
+ address: address,
85
+ child: RichText(
86
+ text: TextSpan(children: spans),
87
+ textAlign: textAlign ?? TextAlign.start,
88
+ overflow: TextOverflow.visible,
89
+ ),
90
);
91
}
92
93
+ /// The visual segmentation turns an address into space-separated pseudo-words
94
+ /// (and, when truncated, into a literal "..."), which cannot be used to verify
95
+ /// an address by ear. Announce the full address instead, exactly the way
96
+ /// `Text.semanticsLabel` does.
97
+ static Widget _withAddressSemantics({required String address, required Widget child}) =>
98
+ Semantics(
99
+ label: address,
100
+ child: ExcludeSemantics(child: child),
101
+ );
102
+
103
static Widget _buildTruncatedAddress({
104
required String address,
105
required bool isMWEB,
@@ -112,10 +125,13 @@ class AddressFormatter {
125
TextSpan(text: lastChunk, style: evenTextStyle),
126
];
127
115
- return RichText(
116
- text: TextSpan(children: spans),
117
- textAlign: textAlign ?? TextAlign.start,
118
- overflow: TextOverflow.visible,
128
+ return _withAddressSemantics(
129
+ address: address,
130
+ child: RichText(
131
+ text: TextSpan(children: spans),
132
+ textAlign: textAlign ?? TextAlign.start,
133
+ overflow: TextOverflow.visible,
134
+ ),
135
);
136
} else {
137
final int digitCount = chunkSize;
@@ -142,10 +158,13 @@ class AddressFormatter {
158
TextSpan(text: lastPart, style: evenTextStyle),
159
];
160
145
- return RichText(
146
- text: TextSpan(children: spans),
147
- textAlign: textAlign ?? TextAlign.start,
148
- overflow: TextOverflow.visible,
161
+ return _withAddressSemantics(
162
+ address: address,
163
+ child: RichText(
164
+ text: TextSpan(children: spans),
165
+ textAlign: textAlign ?? TextAlign.start,
166
+ overflow: TextOverflow.visible,
167
+ ),
168
);
169
}
170
}
res/values/strings_en.arb
+2
@@ -850,9 +850,11 @@
850
"public_key": "Public key",
851
"purchase_gift_card": "Purchase Gift Card",
852
"purple_dark_theme": "Purple Dark Theme",
853
+ "qr_close_fullscreen": "Tap to close full screen QR code",
854
"qr_code_format": "QR Code Format",
855
"qr_code_format_body": "BCUR (Blockchain Commons Uniform Resources) is the industry standard, and most widely compatible with Bitcoin hardware wallets, and should be selected by most users. BBQR (Better Bitcoin QR) is a unique format used only by COLDCARD hardware wallets, starting with their model Q.",
856
"qr_code_format_note": "Different QR code formats are used by different hardware wallets in the Bitcoin space",
857
+ "qr_code_receive_address": "QR code for your receive address",
858
"qr_fullscreen": "Tap to open full screen QR code",
859
"qr_instruction": "Use this address to receive any token or collectible on",
860
"qr_parts_scanned": "Scanned ${scanned} of ${total} QR code parts",