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",