diff --git a/lib/new-ui/pages/scan_page.dart b/lib/new-ui/pages/scan_page.dart index 65182b12f..a3f222cfe 100644 --- a/lib/new-ui/pages/scan_page.dart +++ b/lib/new-ui/pages/scan_page.dart @@ -268,6 +268,7 @@ class _ScanPageState extends State { ), FloatingIconButton( iconPath: "assets/new-ui/paste.svg", + semanticLabel: S.of(context).paste, onPressed: () async { final data = await Clipboard.getData("text/plain"); if (data?.text != null) { diff --git a/lib/new-ui/widgets/coins_page/token_image_widget.dart b/lib/new-ui/widgets/coins_page/token_image_widget.dart index fe2f9e0c9..756417546 100644 --- a/lib/new-ui/widgets/coins_page/token_image_widget.dart +++ b/lib/new-ui/widgets/coins_page/token_image_widget.dart @@ -17,12 +17,17 @@ class TokenImageWidget extends StatefulWidget { required this.imageUrl, required this.size, this.errorWidget, + this.semanticsLabel, }); final String imageUrl; final double size; final Widget? errorWidget; + /// Accessible name for the token artwork. Leave `null` (the default) when the + /// token is already named by adjacent text, so the image stays decorative. + final String? semanticsLabel; + @override State createState() => _TokenImageWidgetState(); } @@ -132,6 +137,7 @@ class _TokenImageWidgetState extends State { fit: BoxFit.cover, filterQuality: FilterQuality.high, errorWidget: widget.errorWidget, + semanticsLabel: widget.semanticsLabel, ), ); diff --git a/lib/new-ui/widgets/receive_page/receive_qr_code.dart b/lib/new-ui/widgets/receive_page/receive_qr_code.dart index 5a8411696..dedc12d3d 100644 --- a/lib/new-ui/widgets/receive_page/receive_qr_code.dart +++ b/lib/new-ui/widgets/receive_page/receive_qr_code.dart @@ -51,85 +51,104 @@ class ReceiveQrCode extends StatelessWidget { height: 45, )), ), - GestureDetector( - onTap: onTap, - behavior: HitTestBehavior.opaque, - child: TweenAnimationBuilder( - tween: Tween(begin: 0, end: targetY), - duration: animDuration, - curve: Curves.easeOutCubic, - builder: (context, value, child) { - return Transform.translate( - offset: Offset(0, value), - child: child, - ); - }, - child: Observer( - builder: (_) => Column( - children: [ - AnimatedScale( - curve: Curves.easeOutCubic, - alignment: Alignment.bottomCenter, - scale: resolvedScale, - duration: animDuration, - child: AnimatedContainer( - duration: animDuration, + // The QR, and the payjoin badge below it, share one tap target, so they + // are merged into a single button node named by the QR's own label + // rather than each becoming a separate focus stop. + MergeSemantics( + child: Semantics( + hint: largeQrMode ? S.of(context).qr_close_fullscreen : S.of(context).qr_fullscreen, + button: true, + enabled: true, + child: GestureDetector( + onTap: onTap, + behavior: HitTestBehavior.opaque, + child: TweenAnimationBuilder( + tween: Tween(begin: 0, end: targetY), + duration: animDuration, + curve: Curves.easeOutCubic, + builder: (context, value, child) { + return Transform.translate( + offset: Offset(0, value), + child: child, + ); + }, + child: Observer( + builder: (_) => Column( + children: [ + AnimatedScale( curve: Curves.easeOutCubic, - width: resolvedSize, - height: resolvedSize, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(20), - color: Colors.white, - ), - child: Container( + alignment: Alignment.bottomCenter, + scale: resolvedScale, + duration: animDuration, + child: AnimatedContainer( + duration: animDuration, + curve: Curves.easeOutCubic, + width: resolvedSize, + height: resolvedSize, decoration: BoxDecoration( - borderRadius: BorderRadius.vertical(top: Radius.circular(20)), - color: addressListViewModel.hasPayjoin && !largeQrMode - ? Theme.of(context).colorScheme.surfaceContainer - : Colors.transparent), - child: ClipRRect( - borderRadius: BorderRadius.circular(20), - child: Observer( - builder: (_) => QrImage( - data: addressListViewModel.uri.toString(), - size: resolvedSize, - embeddedImagePath: - addressListViewModel.tokenCurrency != null - ? addressListViewModel.tokenCurrency == - CryptoCurrency.btcln - ? addressListViewModel.qrImage - : addressListViewModel.tokenCurrency!.iconPath - : addressListViewModel.qrImage, - ))), + borderRadius: BorderRadius.circular(20), + color: Colors.white, + ), + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + color: addressListViewModel.hasPayjoin && !largeQrMode + ? Theme.of(context).colorScheme.surfaceContainer + : Colors.transparent), + child: ClipRRect( + borderRadius: BorderRadius.circular(20), + child: Observer( + builder: (_) => QrImage( + data: addressListViewModel.uri.toString(), + size: resolvedSize, + semanticsLabel: S.of(context).qr_code_receive_address, + embeddedImagePath: + addressListViewModel.tokenCurrency != null + ? addressListViewModel.tokenCurrency == + CryptoCurrency.btcln + ? addressListViewModel.qrImage + : addressListViewModel + .tokenCurrency!.iconPath + : addressListViewModel.qrImage, + ))), + ), ), ), - ), - if (addressListViewModel.hasPayjoin) - Opacity( - opacity: largeQrMode ? 0 : 1, - child: Container( - width: resolvedSize, - decoration: BoxDecoration( - borderRadius: BorderRadius.vertical(bottom: Radius.circular(16)), - color: Theme.of(context).colorScheme.surfaceContainer), - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 2.0), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - spacing: 4, - children: [ - CakeImageWidget(imageUrl: "assets/new-ui/payjoin.svg"), - Text(S.of(context).payjoin_enabled) - ], - ), - ))), - AnimatedSize( - duration: animDuration, - curve: Curves.easeOutCubic, - child: SizedBox(height: largeQrMode ? largeQrModeBottomPadding + 40 : 0)) - ], + if (addressListViewModel.hasPayjoin) + // Hidden but still mounted in large-QR mode, so keep it + // out of the semantics tree there. + ExcludeSemantics( + excluding: largeQrMode, + child: Opacity( + opacity: largeQrMode ? 0 : 1, + child: Container( + width: resolvedSize, + decoration: BoxDecoration( + borderRadius: + BorderRadius.vertical(bottom: Radius.circular(16)), + color: Theme.of(context).colorScheme.surfaceContainer), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 2.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + spacing: 4, + children: [ + CakeImageWidget(imageUrl: "assets/new-ui/payjoin.svg"), + Text(S.of(context).payjoin_enabled) + ], + ), + ))), + ), + AnimatedSize( + duration: animDuration, + curve: Curves.easeOutCubic, + child: SizedBox(height: largeQrMode ? largeQrModeBottomPadding + 40 : 0)) + ], + ), ), - )), + ), + ), + ), ), ], ); diff --git a/lib/new-ui/widgets/send_page/floating_icon_button.dart b/lib/new-ui/widgets/send_page/floating_icon_button.dart index 40a5e5fe3..f07222984 100644 --- a/lib/new-ui/widgets/send_page/floating_icon_button.dart +++ b/lib/new-ui/widgets/send_page/floating_icon_button.dart @@ -3,28 +3,45 @@ import 'package:flutter/material.dart'; import 'package:flutter_svg/svg.dart'; class FloatingIconButton extends StatelessWidget { - const FloatingIconButton({super.key, required this.iconPath, required this.onPressed}); + const FloatingIconButton({ + super.key, + required this.iconPath, + required this.onPressed, + required this.semanticLabel, + }); final String iconPath; final VoidCallback onPressed; + /// Localized accessible name for this icon-only button. The icon itself stays + /// decorative, so this is the only name a screen reader can announce. + final String semanticLabel; + @override Widget build(BuildContext context) { - return Material( - color: Colors.transparent, - borderRadius: const BorderRadius.all(Radius.circular(6)), - child: InkWell( + return MergeSemantics( + child: Semantics( + label: semanticLabel, + button: true, + enabled: true, + child: Material( + color: Colors.transparent, borderRadius: const BorderRadius.all(Radius.circular(6)), - onTap: onPressed, - child: Padding( - padding: const EdgeInsets.all(4.0), - child: CakeImageWidget( - imageUrl: iconPath, - width: 22, - height: 22, - colorFilter: ColorFilter.mode(Theme.of(context).colorScheme.primary, BlendMode.srcIn), - ), - )), + child: InkWell( + borderRadius: const BorderRadius.all(Radius.circular(6)), + onTap: onPressed, + child: Padding( + padding: const EdgeInsets.all(4.0), + child: CakeImageWidget( + imageUrl: iconPath, + width: 22, + height: 22, + colorFilter: + ColorFilter.mode(Theme.of(context).colorScheme.primary, BlendMode.srcIn), + ), + )), + ), + ), ); } } diff --git a/lib/new-ui/widgets/send_page/send_address_input.dart b/lib/new-ui/widgets/send_page/send_address_input.dart index 87fc67fcc..e7ff361b8 100644 --- a/lib/new-ui/widgets/send_page/send_address_input.dart +++ b/lib/new-ui/widgets/send_page/send_address_input.dart @@ -137,16 +137,19 @@ class _NewSendAddressInputState extends State { SizedBox.shrink(), FloatingIconButton( iconPath: "assets/new-ui/paste.svg", + semanticLabel: S.of(context).paste, onPressed: () async { _pasteAddress(context); }), FloatingIconButton( iconPath: "assets/new-ui/scan.svg", + semanticLabel: S.of(context).scan_qr_code, onPressed: () { _presentQRScanner(context); }), FloatingIconButton( iconPath: "assets/new-ui/contacts_outlined.svg", + semanticLabel: S.of(context).address_book, onPressed: () { _presetAddressBookPicker(context); }), diff --git a/lib/new-ui/widgets/send_page/send_amount_input.dart b/lib/new-ui/widgets/send_page/send_amount_input.dart index 051100bff..24e7023ac 100644 --- a/lib/new-ui/widgets/send_page/send_amount_input.dart +++ b/lib/new-ui/widgets/send_page/send_amount_input.dart @@ -97,6 +97,7 @@ class _NewSendAmountInputState extends State { ), FloatingIconButton( iconPath: "assets/new-ui/paste.svg", + semanticLabel: S.of(context).paste, onPressed: () async { final data = await Clipboard.getData(Clipboard.kTextPlain); if (data != null && data.text != null) { diff --git a/lib/new-ui/widgets/send_page/send_memo_input.dart b/lib/new-ui/widgets/send_page/send_memo_input.dart index 5dc69491a..fbc6f56f1 100644 --- a/lib/new-ui/widgets/send_page/send_memo_input.dart +++ b/lib/new-ui/widgets/send_page/send_memo_input.dart @@ -46,6 +46,7 @@ class NewSendMemoInput extends StatelessWidget { SizedBox(width: 12), FloatingIconButton( iconPath: "assets/new-ui/paste.svg", + semanticLabel: S.of(context).paste, onPressed: () async { final data = await Clipboard.getData(Clipboard.kTextPlain); if (data != null && data.text != null) { diff --git a/lib/src/screens/receive/widgets/qr_image.dart b/lib/src/screens/receive/widgets/qr_image.dart index da856dfe9..7ff2fb2c7 100644 --- a/lib/src/screens/receive/widgets/qr_image.dart +++ b/lib/src/screens/receive/widgets/qr_image.dart @@ -1,3 +1,4 @@ +import 'package:cake_wallet/generated/i18n.dart'; import 'package:cake_wallet/new-ui/widgets/coins_page/token_image_widget.dart'; import 'package:cake_wallet/src/widgets/cake_image_widget.dart'; import 'package:flutter/material.dart'; @@ -13,6 +14,7 @@ class QrImage extends StatelessWidget { this.errorCorrectionLevel = qr.QrErrorCorrectLevel.H, this.embeddedImagePath, this.badgeImageOnEmbeddedImagePath, + this.semanticsLabel, }); final double? size; @@ -24,6 +26,11 @@ class QrImage extends StatelessWidget { final String? embeddedImagePath; final String? badgeImageOnEmbeddedImagePath; + /// Accessible name announced for the code itself. Defaults to a generic + /// localized "QR code" so the code is always discoverable; pass a more + /// specific label when the host screen knows what the code encodes. + final String? semanticsLabel; + @override Widget build(BuildContext context) { final imagePath = embeddedImagePath ?? 'assets/images/qr-cake.png'; @@ -68,8 +75,13 @@ class QrImage extends StatelessWidget { foregroundColor: foregroundColor, backgroundColor: backgroundColor, padding: const EdgeInsets.all(12.0), + // The package default is a hardcoded English "qr code"; use the + // localized string so the code is announced in the user's language. + semanticsLabel: semanticsLabel ?? S.of(context).scan_qr_code, ), - centerImageToUse, + // The embedded logo and its badge are purely decorative — the node + // above already announces the code — so keep them out of the tree. + ExcludeSemantics(child: centerImageToUse), ], ); } diff --git a/lib/src/widgets/cake_image_widget.dart b/lib/src/widgets/cake_image_widget.dart index efd843504..206e7613e 100644 --- a/lib/src/widgets/cake_image_widget.dart +++ b/lib/src/widgets/cake_image_widget.dart @@ -18,6 +18,7 @@ class CakeImageWidget extends StatelessWidget { this.alignment, this.allowDrawingOutsideViewBox, this.filterQuality, + this.semanticsLabel, }); final String? imageUrl; @@ -33,6 +34,16 @@ class CakeImageWidget extends StatelessWidget { final double borderRadius; final FilterQuality? filterQuality; + /// Accessible name for this image. + /// + /// Leave `null` (the default) for decorative imagery — the image then + /// contributes nothing at all to the semantics tree, so screen readers do not + /// stop on an unnamed node. Pass a localized string only when the image is the + /// sole carrier of information for the user. + final String? semanticsLabel; + + bool get _isDecorative => semanticsLabel == null; + @override Widget build(BuildContext context) { if (imageUrl == null || imageUrl!.isEmpty) { @@ -53,6 +64,8 @@ class CakeImageWidget extends StatelessWidget { alignment: alignment ?? Alignment.center, allowDrawingOutsideViewBox: allowDrawingOutsideViewBox ?? false, colorFilter: effectiveColorFilter, + semanticsLabel: semanticsLabel, + excludeFromSemantics: _isDecorative, fit: fit ?? BoxFit.contain, errorBuilder: (context, e, trace) { return SvgPicture.asset( imageUrl!, @@ -62,6 +75,8 @@ class CakeImageWidget extends StatelessWidget { width: width, errorBuilder: (_, __, ___) => SizedBox(height: height, width: width), colorFilter: effectiveColorFilter, + semanticsLabel: semanticsLabel, + excludeFromSemantics: _isDecorative, fit: fit ?? BoxFit.contain, ); }); @@ -73,6 +88,8 @@ class CakeImageWidget extends StatelessWidget { fit: fit, color: color, filterQuality: filterQuality ?? FilterQuality.medium, + semanticLabel: semanticsLabel, + excludeFromSemantics: _isDecorative, errorBuilder: (_, __, ___) => _buildErrorWidget(context), ); } @@ -86,13 +103,9 @@ class CakeImageWidget extends StatelessWidget { alignment: alignment ?? Alignment.center, allowDrawingOutsideViewBox: allowDrawingOutsideViewBox ?? false, fit: fit ?? BoxFit.contain, - placeholderBuilder: (_) { - return loadingWidget ?? - SizedBox( - height: height, - width: width, - child: Center(child: CupertinoActivityIndicator())); - }, + semanticsLabel: semanticsLabel, + excludeFromSemantics: _isDecorative, + placeholderBuilder: (_) => _buildLoadingWidget(), errorBuilder: (_, __, ___) => _buildErrorWidget(context), ) : Image.network( @@ -102,13 +115,11 @@ class CakeImageWidget extends StatelessWidget { fit: fit ?? BoxFit.cover, color: color, filterQuality: filterQuality ?? FilterQuality.medium, + semanticLabel: semanticsLabel, + excludeFromSemantics: _isDecorative, loadingBuilder: (_, Widget child, ImageChunkEvent? progress) { if (progress == null) return child; - return loadingWidget ?? - SizedBox( - height: height, - width: width, - child: Center(child: CupertinoActivityIndicator())); + return _buildLoadingWidget(); }, errorBuilder: (_, __, ___) => _buildErrorWidget(context), ); @@ -117,8 +128,16 @@ class CakeImageWidget extends StatelessWidget { return imageWidget; } + /// A caller-supplied [loadingWidget] owns its own semantics; the built-in + /// spinner is purely visual and must not become an unnamed focus stop. + Widget _buildLoadingWidget() => + loadingWidget ?? const ExcludeSemantics(child:SizedBox( + height: height, + width: width, + child: Center(child: CupertinoActivityIndicator()))); + Widget _buildErrorWidget(BuildContext context) { - return Container( + final Widget placeholder = Container( height: height, width: width, decoration: BoxDecoration( @@ -134,5 +153,20 @@ class CakeImageWidget extends StatelessWidget { ), ), ); + + // Several call sites render meaningful content (e.g. the asset's initials) as + // their [errorWidget], so leave its semantics to the caller. + if (errorWidget != null) { + return placeholder; + } + + return _isDecorative + ? ExcludeSemantics(child: placeholder) + : Semantics( + container: true, + image: true, + label: semanticsLabel, + child: ExcludeSemantics(child: placeholder), + ); } } diff --git a/lib/src/widgets/rounded_checkbox.dart b/lib/src/widgets/rounded_checkbox.dart index 97a5ffda4..36ca4198b 100644 --- a/lib/src/widgets/rounded_checkbox.dart +++ b/lib/src/widgets/rounded_checkbox.dart @@ -9,7 +9,9 @@ class RoundedCheckbox extends StatelessWidget { @override Widget build(BuildContext context) { - return value + // Unchecked renders nothing at all, so without an explicit `checked` state + // the selection is invisible to screen readers. + final Widget indicator = value ? Container( height: 20.0, width: 20.0, @@ -23,5 +25,9 @@ class RoundedCheckbox extends StatelessWidget { size: 14.0, )) : Offstage(); + + // Deliberately not a semantics container: the state merges into the + // enclosing row/option node instead of adding a second focus stop. + return Semantics(checked: value, child: indicator); } } diff --git a/lib/utils/address_formatter.dart b/lib/utils/address_formatter.dart index f39ab59cb..9b96b9f3d 100644 --- a/lib/utils/address_formatter.dart +++ b/lib/utils/address_formatter.dart @@ -80,13 +80,26 @@ class AddressFormatter { spans.add(TextSpan(text: '${chunks[i]} ', style: style)); } - return RichText( - text: TextSpan(children: spans), - textAlign: textAlign ?? TextAlign.start, - overflow: TextOverflow.visible, + return _withAddressSemantics( + address: address, + child: RichText( + text: TextSpan(children: spans), + textAlign: textAlign ?? TextAlign.start, + overflow: TextOverflow.visible, + ), ); } + /// The visual segmentation turns an address into space-separated pseudo-words + /// (and, when truncated, into a literal "..."), which cannot be used to verify + /// an address by ear. Announce the full address instead, exactly the way + /// `Text.semanticsLabel` does. + static Widget _withAddressSemantics({required String address, required Widget child}) => + Semantics( + label: address, + child: ExcludeSemantics(child: child), + ); + static Widget _buildTruncatedAddress({ required String address, required bool isMWEB, @@ -112,10 +125,13 @@ class AddressFormatter { TextSpan(text: lastChunk, style: evenTextStyle), ]; - return RichText( - text: TextSpan(children: spans), - textAlign: textAlign ?? TextAlign.start, - overflow: TextOverflow.visible, + return _withAddressSemantics( + address: address, + child: RichText( + text: TextSpan(children: spans), + textAlign: textAlign ?? TextAlign.start, + overflow: TextOverflow.visible, + ), ); } else { final int digitCount = chunkSize; @@ -142,10 +158,13 @@ class AddressFormatter { TextSpan(text: lastPart, style: evenTextStyle), ]; - return RichText( - text: TextSpan(children: spans), - textAlign: textAlign ?? TextAlign.start, - overflow: TextOverflow.visible, + return _withAddressSemantics( + address: address, + child: RichText( + text: TextSpan(children: spans), + textAlign: textAlign ?? TextAlign.start, + overflow: TextOverflow.visible, + ), ); } } diff --git a/res/values/strings_en.arb b/res/values/strings_en.arb index 40896399e..09f01168c 100644 --- a/res/values/strings_en.arb +++ b/res/values/strings_en.arb @@ -97,6 +97,7 @@ "available_balance": "Available Balance", "available_balance_description": "The “Available Balance” or “Confirmed Balance” are funds that can be spent immediately. If funds appear in the lower balance but not the top balance, then you must wait a few minutes for the incoming funds to get more network confirmations. After they get more confirmations, they will be spendable.", "available_balance_short": "Avl: ${amount}", + "available_providers": "Available Providers", "avg_savings": "Avg. Savings", "avl": "Avl", "awaitDAppProcessing": "Kindly wait for the dApp to finish processing.", @@ -866,9 +867,11 @@ "purchase_gift_card": "Purchase Gift Card", "purchase_with": "Purchase with", "purple_dark_theme": "Purple Dark Theme", + "qr_close_fullscreen": "Tap to close full screen QR code", "qr_code_format": "QR Code Format", "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.", "qr_code_format_note": "Different QR code formats are used by different hardware wallets in the Bitcoin space", + "qr_code_receive_address": "QR code for your receive address", "qr_fullscreen": "Tap to open full screen QR code", "qr_instruction": "Use this address to receive any token or collectible on", "qr_parts_scanned": "Scanned ${scanned} of ${total} QR code parts", diff --git a/scripts/prepare_torch.sh b/scripts/prepare_torch.sh index 81c1190e3..06deb934d 100755 --- a/scripts/prepare_torch.sh +++ b/scripts/prepare_torch.sh @@ -7,7 +7,7 @@ cd "$(dirname "$0")" # - .github/workflows/pr_test_build_linux.yml # https://github.com/MrCyjaneK/torch_dart/releases/download/v1.0.17/torch_dart-v1.0.17.tar.gz -HASH=79e5361f43b8aadd340d2900009cf09590b2a0bb +HASH=a8601eaef00ac4a9465a0979b7d7a16df90bc364 if [[ ! -d "torch_dart/.git" ]]; then