diff --git a/lib/entities/qr_scanner.dart b/lib/entities/qr_scanner.dart index ad076cd8a..296d3c5e4 100644 --- a/lib/entities/qr_scanner.dart +++ b/lib/entities/qr_scanner.dart @@ -69,7 +69,7 @@ class _BarcodeScannerSimpleState extends State { return AlertWithOneAction( alertTitle: S.of(context).error, alertContent: S.of(context).error_dialog_content, - buttonText: 'ok', + buttonText: S.of(context).ok, buttonAction: () { Navigator.of(context).pop(); }, diff --git a/lib/new-ui/pages/scan_page.dart b/lib/new-ui/pages/scan_page.dart index 695382be1..65182b12f 100644 --- a/lib/new-ui/pages/scan_page.dart +++ b/lib/new-ui/pages/scan_page.dart @@ -76,46 +76,50 @@ class _ScanPageState extends State { controller: controller, onDetect: _handleBarcode, ), + // A full screen tap target would otherwise show up as one giant unlabeled + // node; leaving text mode is also possible with the back button. Positioned.fill( - child: GestureDetector( - onTap: () => setState(() { - _textInputMode = false; - }), - child: RepaintBoundary( - child: AnimatedSwitcher( - switchInCurve: Curves.easeOutCubic, - switchOutCurve: Curves.easeInQuad, - duration: textModeSwitchDuration, - child: _textInputMode - ? BackdropFilter( - key: ValueKey(1), - filter: ImageFilter.blur(sigmaX: 8.0, sigmaY: 8.0), - child: Container( - color: _frontFlashMode ? Colors.white : Colors.black.withAlpha(153), - ), - ) - : TweenAnimationBuilder( - key: const ValueKey(0), - tween: Tween(begin: 0.0, end: targetRadius), - duration: const Duration(milliseconds: 500), - curve: Curves.easeOutCubic, - child: BackdropFilter( + child: ExcludeSemantics( + child: GestureDetector( + onTap: () => setState(() { + _textInputMode = false; + }), + child: RepaintBoundary( + child: AnimatedSwitcher( + switchInCurve: Curves.easeOutCubic, + switchOutCurve: Curves.easeInQuad, + duration: textModeSwitchDuration, + child: _textInputMode + ? BackdropFilter( + key: ValueKey(1), filter: ImageFilter.blur(sigmaX: 8.0, sigmaY: 8.0), child: Container( color: _frontFlashMode ? Colors.white : Colors.black.withAlpha(153), ), - ), - builder: (context, radius, child) { - return ClipPath( - clipper: HoleClipper( - width: cutoutSize, - height: cutoutSize, - radius: radius, + ) + : TweenAnimationBuilder( + key: const ValueKey(0), + tween: Tween(begin: 0.0, end: targetRadius), + duration: const Duration(milliseconds: 500), + curve: Curves.easeOutCubic, + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 8.0, sigmaY: 8.0), + child: Container( + color: _frontFlashMode ? Colors.white : Colors.black.withAlpha(153), ), - child: child, - ); - }, - ), + ), + builder: (context, radius, child) { + return ClipPath( + clipper: HoleClipper( + width: cutoutSize, + height: cutoutSize, + radius: radius, + ), + child: child, + ); + }, + ), + ), ), ), ), @@ -241,20 +245,25 @@ class _ScanPageState extends State { spacing: 10, children: [ Expanded( - child: TextField( - enabled: _textInputMode, - controller: textController, - focusNode: textFocusNode, - onSubmitted: (val) { - if (val.isNotEmpty) { - Navigator.of(context).pop(val); - } else { - setState(() { - _textInputMode = false; - }); - } - }, - decoration: InputDecoration(hintText: S.of(context).enter_code), + child: MergeSemantics( + child: Semantics( + label: S.of(context).enter_code, + child: TextField( + enabled: _textInputMode, + controller: textController, + focusNode: textFocusNode, + onSubmitted: (val) { + if (val.isNotEmpty) { + Navigator.of(context).pop(val); + } else { + setState(() { + _textInputMode = false; + }); + } + }, + decoration: InputDecoration(hintText: S.of(context).enter_code), + ), + ), ), ), FloatingIconButton( @@ -336,6 +345,7 @@ class _ScanPageState extends State { } }, icon: Icons.question_mark, + semanticsLabel: S.of(context).help, buttonColor: buttonColor, buttonIconColor: buttonIconColor) ], @@ -366,27 +376,39 @@ class _ScanPageState extends State { child: AnimatedOpacity( duration: Duration(milliseconds: 500), opacity: isScanningURQR ? 1 : 0, - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - "${decoder.processedPartsCount()}", - style: TextStyle( - fontSize: 45, - fontWeight: FontWeight.w500, - color: Theme.of(context).colorScheme.primary), + // Three separate digits mean nothing on their own: announce the progress + // as one node, and only while a multi part code is actually being scanned. + child: ExcludeSemantics( + excluding: !isScanningURQR, + child: Semantics( + container: true, + liveRegion: true, + label: S.of(context).qr_parts_scanned( + "${decoder.processedPartsCount()}", "${decoder.expectedPartCount() ?? 0}"), + excludeSemantics: true, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + "${decoder.processedPartsCount()}", + style: TextStyle( + fontSize: 45, + fontWeight: FontWeight.w500, + color: Theme.of(context).colorScheme.primary), + ), + Text( + "/", + style: TextStyle( + fontSize: 45, color: Theme.of(context).colorScheme.onSurfaceVariant), + ), + Text( + "${decoder.expectedPartCount()}", + style: TextStyle( + fontSize: 45, color: Theme.of(context).colorScheme.onSurface), + ), + ], ), - Text( - "/", - style: TextStyle( - fontSize: 45, color: Theme.of(context).colorScheme.onSurfaceVariant), - ), - Text( - "${decoder.expectedPartCount()}", - style: - TextStyle(fontSize: 45, color: Theme.of(context).colorScheme.onSurface), - ), - ], + ), ), ), ), @@ -406,7 +428,7 @@ class _ScanPageState extends State { return AlertWithOneAction( alertTitle: S.of(context).error, alertContent: S.of(context).error_dialog_content, - buttonText: 'ok', + buttonText: S.of(context).ok, buttonAction: () { Navigator.of(context).pop(); }, @@ -459,37 +481,51 @@ class ScanPageButton extends StatelessWidget { required this.onTap, required this.icon, this.label, + this.semanticsLabel, required this.buttonColor, required this.buttonIconColor}); final VoidCallback onTap; final IconData icon; final String? label; + + /// Name for the icon-only variant, which has no visible [label]. + final String? semanticsLabel; final Color buttonColor; final Color buttonIconColor; @override Widget build(BuildContext context) { - return GestureDetector( + return Semantics( + button: true, + enabled: true, + label: semanticsLabel ?? label, onTap: onTap, - behavior: HitTestBehavior.opaque, - child: Container( - decoration: BoxDecoration(color: buttonColor, borderRadius: BorderRadius.circular(99999)), - child: Padding( - padding: EdgeInsets.only( - top: 10, bottom: 10, left: label == null ? 10 : 16, right: label == null ? 10 : 20), - child: Row( - spacing: 10, - children: [ - Icon(icon, size: 28, color: buttonIconColor), - if (label != null) - Text(label!, - style: TextStyle( - fontSize: 16, fontWeight: FontWeight.w500, color: buttonIconColor)) - ], - ), - ), - )); + excludeSemantics: true, + child: GestureDetector( + onTap: onTap, + behavior: HitTestBehavior.opaque, + child: Container( + decoration: + BoxDecoration(color: buttonColor, borderRadius: BorderRadius.circular(99999)), + child: Padding( + padding: EdgeInsets.only( + top: 10, + bottom: 10, + left: label == null ? 10 : 16, + right: label == null ? 10 : 20), + child: Row( + spacing: 10, + children: [ + Icon(icon, size: 28, color: buttonIconColor), + if (label != null) + Text(label!, + style: TextStyle( + fontSize: 16, fontWeight: FontWeight.w500, color: buttonIconColor)) + ], + ), + ), + ))); } } diff --git a/lib/new-ui/pages/send_page.dart b/lib/new-ui/pages/send_page.dart index f03381734..d25b3440e 100644 --- a/lib/new-ui/pages/send_page.dart +++ b/lib/new-ui/pages/send_page.dart @@ -417,7 +417,12 @@ class _NewSendPageState extends State { crossAxisAlignment: CrossAxisAlignment.start, spacing: 12, children: [ - Text(S.of(context).address_or_alias), + // NewSendAddressInput merges this label onto its + // own text-field node, so announcing the caption + // as well would read it twice. + ExcludeSemantics( + child: Text(S.of(context).address_or_alias), + ), NewSendAddressInput( displayName: output.displayName, validator: output.isParsedAddress @@ -481,6 +486,12 @@ class _NewSendPageState extends State { crossAxisAlignment: CrossAxisAlignment.start, spacing: 12, children: [ + // This caption is the amount field's accessible name + // and must stay in the semantics tree: + // NewSendAmountInput deliberately carries no label of + // its own, because labelling the field made Android + // announce the amount twice. Covered by + // test/new-ui/widgets/send_page/send_amount_input_test.dart. Text(S.of(context).amount), NewSendAmountInput( validator: output.sendAll @@ -558,17 +569,18 @@ class _NewSendPageState extends State { label: S.of(context).fees, subtitle: "~${output.estimatedFee} ${widget.sendViewModel.currencySymbol} (${output.estimatedFeeFiatAmount} ${widget.sendViewModel.fiatCurrency})", - onTap: () { - if (widget.sendViewModel.feesViewModel - .hasFeesPriority) { - pickTransactionPriority(context, output); - } - }, + // Without fee priorities the row does nothing, + // so it must not be announced as interactive. + onTap: widget.sendViewModel.feesViewModel + .hasFeesPriority + ? () => + pickTransactionPriority(context, output) + : null, ), if (widget.sendViewModel.hasCoinControl) ListItemRegularRowWidget( keyValue: "", - label: "Coin Control", + label: S.of(context).coin_control, onTap: () { showCupertinoModalBottomSheet( enableDrag: false, diff --git a/lib/new-ui/widgets/dropdown_row.dart b/lib/new-ui/widgets/dropdown_row.dart index d06a2b291..7772cd8a9 100644 --- a/lib/new-ui/widgets/dropdown_row.dart +++ b/lib/new-ui/widgets/dropdown_row.dart @@ -11,38 +11,45 @@ class DropdownRow extends StatelessWidget { @override Widget build(BuildContext context) { - return Container( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainer, - borderRadius: BorderRadius.vertical( - top: Radius.circular(18), - bottom: expanded ? Radius.zero : Radius.circular(18), + return Semantics( + button: true, + expanded: expanded, + label: text, + onTap: onTap, + excludeSemantics: true, + child: Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainer, + borderRadius: BorderRadius.vertical( + top: Radius.circular(18), + bottom: expanded ? Radius.zero : Radius.circular(18), + ), ), - ), - height: 48, - child: Material( - color: Colors.transparent, - child: InkWell( - onTap: onTap, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - text, - style: TextStyle(fontSize: 14, color: Theme.of(context).colorScheme.primary), - ), - AnimatedRotation( - duration: Duration(milliseconds: 300), - turns: expanded ? 0.0 : 0.5, - curve: Curves.easeOut, - child: CakeImageWidget( - imageUrl: "assets/new-ui/dropdown_arrow.svg", - colorFilter: - ColorFilter.mode(Theme.of(context).colorScheme.primary, BlendMode.srcIn), - )) - ], + height: 48, + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + text, + style: TextStyle(fontSize: 14, color: Theme.of(context).colorScheme.primary), + ), + AnimatedRotation( + duration: Duration(milliseconds: 300), + turns: expanded ? 0.0 : 0.5, + curve: Curves.easeOut, + child: CakeImageWidget( + imageUrl: "assets/new-ui/dropdown_arrow.svg", + colorFilter: ColorFilter.mode( + Theme.of(context).colorScheme.primary, BlendMode.srcIn), + )) + ], + ), ), ), ), diff --git a/lib/new-ui/widgets/scan_page/network_list.dart b/lib/new-ui/widgets/scan_page/network_list.dart index 007ba5167..4cc738188 100644 --- a/lib/new-ui/widgets/scan_page/network_list.dart +++ b/lib/new-ui/widgets/scan_page/network_list.dart @@ -11,6 +11,20 @@ import 'package:url_launcher/url_launcher.dart'; class ScanPageNetworkList extends StatelessWidget { const ScanPageNetworkList({super.key}); + /// Same icon the row would render from `trailingIconPath`, but it now carries the + /// information that following the row leaves the app. + Widget _externalLinkIcon(BuildContext context) => Semantics( + label: S.of(context).opens_in_browser, + excludeSemantics: true, + child: CakeImageWidget( + imageUrl: "assets/new-ui/external_link.svg", + height: 18, + width: 18, + colorFilter: + ColorFilter.mode(Theme.of(context).colorScheme.onSurfaceVariant, BlendMode.srcIn), + ), + ); + @override Widget build(BuildContext context) { return Container( @@ -81,7 +95,7 @@ class ScanPageNetworkList extends StatelessWidget { keyValue: "wc", label: "WalletConnect", subtitle: S.of(context).wc_desc, - trailingIconPath: "assets/new-ui/external_link.svg", + trailingWidget: _externalLinkIcon(context), onTap: () { launchUrl(Uri.https("walletconnect.com")); }), @@ -90,7 +104,7 @@ class ScanPageNetworkList extends StatelessWidget { keyValue: "ocp", label: "OpenCryptoPay", subtitle: S.of(context).ocp_desc, - trailingIconPath: "assets/new-ui/external_link.svg", + trailingWidget: _externalLinkIcon(context), onTap: () { launchUrl(Uri.https("opencryptopay.io")); }) diff --git a/lib/new-ui/widgets/send_page/fiat_amount_bar.dart b/lib/new-ui/widgets/send_page/fiat_amount_bar.dart index 76cbcfc23..338cdc676 100644 --- a/lib/new-ui/widgets/send_page/fiat_amount_bar.dart +++ b/lib/new-ui/widgets/send_page/fiat_amount_bar.dart @@ -35,46 +35,65 @@ class FiatAmountBar extends StatelessWidget { final Color? allAmountTextColor; @override - Widget build(BuildContext context) => Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ + Widget build(BuildContext context) { + final convertedAmount = fiatInputMode + ? "${cryptoAmount.isEmpty ? "0" : cryptoAmount.withMaxDecimals(8)} $cryptoCurrencySymbol" + : "${fiatAmount.isEmpty ? "0" : fiatAmount} $fiatCurrencySymbol"; + + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + spacing: 8, + children: [ + ModernButton.svg( + backgroundColor: foregroundElementColor, + size: 28, + svgPath: "assets/new-ui/switch.svg", + iconSize: 18, + onPressed: onSwitchButtonPressed, + semanticLabel: S.of(context).switch_input_currency, + ), + // Announced as the converted value only: the switch button next to it already + // exposes the same action, so this must not become a second control. + Semantics( + label: convertedAmount, + excludeSemantics: true, + child: GestureDetector( + onTap: onSwitchButtonPressed, + child: Text( + convertedAmount, + style: TextStyle(color: textColor ?? Theme.of(context).colorScheme.onSurface), + ), + ), + ), + ], + ), + if (allAmount != null && allAmount!.isNotEmpty) Row( spacing: 8, children: [ - ModernButton.svg( - backgroundColor: foregroundElementColor, - size: 28, - svgPath: "assets/new-ui/switch.svg", - iconSize: 18, - onPressed: onSwitchButtonPressed, - semanticLabel: S.of(context).switch_input_currency, - ), - GestureDetector( - onTap: onSwitchButtonPressed, + // The caption is part of the chip's label below. + ExcludeSemantics( child: Text( - fiatInputMode - ? "${cryptoAmount.isEmpty ? "0" : cryptoAmount.withMaxDecimals(8)} $cryptoCurrencySymbol" - : "${fiatAmount.isEmpty ? "0" : fiatAmount} $fiatCurrencySymbol", - style: TextStyle(color: textColor ?? Theme.of(context).colorScheme.onSurface), - ), - ), - ], - ), - if (allAmount != null && allAmount!.isNotEmpty) - Row( - spacing: 8, - children: [ - Text( "${S.of(context).max}.", style: TextStyle(color: textColor ?? Theme.of(context).colorScheme.onSurface), ), - Container( - decoration: BoxDecoration(borderRadius: BorderRadius.circular(999999)), - child: Material( - color: allAmountColor ?? - foregroundElementColor ?? - Theme.of(context).colorScheme.surfaceContainer, - borderRadius: BorderRadius.circular(99999), + ), + Container( + decoration: BoxDecoration(borderRadius: BorderRadius.circular(999999)), + child: Material( + color: allAmountColor ?? + foregroundElementColor ?? + Theme.of(context).colorScheme.surfaceContainer, + borderRadius: BorderRadius.circular(99999), + child: Semantics( + button: true, + enabled: onAllButtonPressed != null, + label: S.of(context).max, + value: _formatAmount(allAmount!), + onTap: onAllButtonPressed, + excludeSemantics: true, child: InkWell( borderRadius: BorderRadius.circular(99999), onTap: onAllButtonPressed, @@ -90,10 +109,12 @@ class FiatAmountBar extends StatelessWidget { ), ), ), - ], - ), - ], - ); + ), + ], + ), + ], + ); + } String _formatAmount(String amount) { try { diff --git a/lib/new-ui/widgets/send_page/recipient_dot_row.dart b/lib/new-ui/widgets/send_page/recipient_dot_row.dart index 18c8f3df0..29ec16fe5 100644 --- a/lib/new-ui/widgets/send_page/recipient_dot_row.dart +++ b/lib/new-ui/widgets/send_page/recipient_dot_row.dart @@ -1,3 +1,4 @@ +import 'package:cake_wallet/generated/i18n.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; @@ -179,32 +180,44 @@ class _RecipientDotState extends State { curve: Curves.easeOutCubic, top: _created ? 0 : widget.size, child: Material( - child: AnimatedSwitcher( - duration: Duration(milliseconds: 150), - child: GestureDetector( - key: ValueKey(widget.selected), + // Dots scaled all the way down are not visible, so they must not be + // reachable by a screen reader either. + child: ExcludeSemantics( + excluding: widget.textOpacity <= 0, + child: Semantics( + button: true, + selected: widget.selected, + label: S.of(context).recipient_number("${widget.index + 1}"), onTap: widget.onTap, - child: Container( - width: widget.size, - height: widget.size, - margin: EdgeInsets.symmetric(horizontal: widget.spacing / 2), - decoration: BoxDecoration( - color: widget.selected - ? Theme.of(context).colorScheme.primary - : Theme.of(context).colorScheme.surfaceContainer, - shape: BoxShape.circle, - ), - alignment: Alignment.center, - child: Opacity( - opacity: widget.textOpacity, - child: Text( - (widget.index + 1).toString(), - style: TextStyle( - color: widget.selected - ? Theme.of(context).colorScheme.onPrimary - : Theme.of(context).colorScheme.onSurface, - fontSize: 14, - fontWeight: FontWeight.w500), + excludeSemantics: true, + child: AnimatedSwitcher( + duration: Duration(milliseconds: 150), + child: GestureDetector( + key: ValueKey(widget.selected), + onTap: widget.onTap, + child: Container( + width: widget.size, + height: widget.size, + margin: EdgeInsets.symmetric(horizontal: widget.spacing / 2), + decoration: BoxDecoration( + color: widget.selected + ? Theme.of(context).colorScheme.primary + : Theme.of(context).colorScheme.surfaceContainer, + shape: BoxShape.circle, + ), + alignment: Alignment.center, + child: Opacity( + opacity: widget.textOpacity, + child: Text( + (widget.index + 1).toString(), + style: TextStyle( + color: widget.selected + ? Theme.of(context).colorScheme.onPrimary + : Theme.of(context).colorScheme.onSurface, + fontSize: 14, + fontWeight: FontWeight.w500), + ), + ), ), ), ), 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 704a41571..87fc67fcc 100644 --- a/lib/new-ui/widgets/send_page/send_address_input.dart +++ b/lib/new-ui/widgets/send_page/send_address_input.dart @@ -86,22 +86,27 @@ class _NewSendAddressInputState extends State { Expanded( child: Stack( children: [ - TextField( - focusNode: widget.focusNode, - autocorrect: false, - enableSuggestions: false, - onSubmitted: (val) => FocusScope.of(context).unfocus(), - onChanged: state.didChange, - onEditingComplete: () { - widget.onEditingComplete(); - }, - onTapOutside: (_) { - widget.onEditingComplete(); - }, - controller: widget.addressController, - decoration: InputDecoration( - hintText: widget.hintText ?? S.of(context).search_or_enter, - errorMaxLines: 3, + MergeSemantics( + child: Semantics( + label: _fieldSemanticsLabel(context), + child: TextField( + focusNode: widget.focusNode, + autocorrect: false, + enableSuggestions: false, + onSubmitted: (val) => FocusScope.of(context).unfocus(), + onChanged: state.didChange, + onEditingComplete: () { + widget.onEditingComplete(); + }, + onTapOutside: (_) { + widget.onEditingComplete(); + }, + controller: widget.addressController, + decoration: InputDecoration( + hintText: widget.hintText ?? S.of(context).search_or_enter, + errorMaxLines: 3, + ), + ), ), ), Positioned.fill( @@ -113,9 +118,13 @@ class _NewSendAddressInputState extends State { widget.addressController.text.isEmpty) ? 0 : 1, - child: SendAddressOverlay( - address: widget.addressController.text, - displayName: widget.displayName, + // Purely visual copy of the field content; announcing it again + // would read the address twice. + child: ExcludeSemantics( + child: SendAddressOverlay( + address: widget.addressController.text, + displayName: widget.displayName, + ), )), ), ), @@ -150,9 +159,15 @@ class _NewSendAddressInputState extends State { if (state.hasError) Padding( padding: EdgeInsets.only(top: 6, left: 8), - child: Text( - state.errorText!, - style: TextStyle(fontSize: 12, color: Theme.of(context).colorScheme.error), + child: Semantics( + container: true, + liveRegion: true, + label: "${S.of(context).address_or_alias}, ${state.errorText!}", + excludeSemantics: true, + child: Text( + state.errorText!, + style: TextStyle(fontSize: 12, color: Theme.of(context).colorScheme.error), + ), ), ) ], @@ -161,6 +176,19 @@ class _NewSendAddressInputState extends State { ); } + String _fieldSemanticsLabel(BuildContext context) { + final label = S.of(context).address_or_alias; + final displayName = widget.displayName; + + if (displayName == null || + displayName.isEmpty || + displayName == widget.addressController.text) { + return label; + } + + return "$label, $displayName"; + } + Future _presentQRScanner(BuildContext context) async { bool isCameraPermissionGranted = await PermissionHandler.checkPermission(Permission.camera, 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 ff905d08e..051100bff 100644 --- a/lib/new-ui/widgets/send_page/send_amount_input.dart +++ b/lib/new-ui/widgets/send_page/send_amount_input.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/new-ui/widgets/send_page/floating_icon_button.dart"; import "package:cake_wallet/src/widgets/cake_image_widget.dart"; @@ -67,6 +68,14 @@ class _NewSendAmountInputState extends State { mainAxisSize: MainAxisSize.max, spacing: 8, children: [ + // Deliberately unwrapped. Naming the field with + // MergeSemantics > Semantics(label:) made Android announce + // the amount twice: FormField's own wrapper node reflects + // its descendants' text, so the authored label came back a + // second time on the container. The visible "Amount:" + // caption on the send page carries the name instead, and it + // must stay in the semantics tree for this field to be + // named at all. Expanded( child: TextField( keyboardType: TextInputType.numberWithOptions( @@ -105,44 +114,58 @@ class _NewSendAmountInputState extends State { ), IntrinsicWidth( child: Observer( - builder: (_) => GestureDetector( - onTap: widget.onPickerClicked, - child: Container( - alignment: Alignment.center, - width: double.infinity, - decoration: BoxDecoration( - borderRadius: const BorderRadius.only( - topRight: Radius.circular(18), - bottomRight: Radius.circular(18), + // `container: true` is load bearing. FormField wraps this + // builder in its own non-container Semantics annotation, so + // without a container of its own this configuration is + // absorbed into that wrapper node — which then parents the + // amount TextField and hides it from screen readers. + builder: (_) => Semantics( + container: true, + button: widget.hasPicker ? true : null, + enabled: widget.hasPicker ? true : null, + label: widget.hasPicker ? S.of(context).select_asset : widget.currency, + value: widget.hasPicker ? widget.currency : null, + onTap: widget.hasPicker ? widget.onPickerClicked : null, + excludeSemantics: true, + child: GestureDetector( + onTap: widget.onPickerClicked, + child: Container( + alignment: Alignment.center, + width: double.infinity, + decoration: BoxDecoration( + borderRadius: const BorderRadius.only( + topRight: Radius.circular(18), + bottomRight: Radius.circular(18), + ), + color: widget.hasPicker + ? Theme.of(context).colorScheme.surfaceContainerHigh + : Theme.of(context).colorScheme.surfaceContainer, ), - color: widget.hasPicker - ? Theme.of(context).colorScheme.surfaceContainerHigh - : Theme.of(context).colorScheme.surfaceContainer, - ), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 12), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - mainAxisSize: MainAxisSize.max, - spacing: 8, - children: [ - if (widget.hasPicker && widget.currencyIconPath.isNotEmpty) - TokenImageWidget( - imageUrl: widget.currencyIconPath, - size: 24, - ), - Text(widget.currency), - if (widget.hasPicker) - CakeImageWidget( - imageUrl: "assets/new-ui/chooser.svg", - width: 12, - height: 12, - colorFilter: ColorFilter.mode( - Theme.of(context).colorScheme.primary, - BlendMode.srcIn, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.max, + spacing: 8, + children: [ + if (widget.hasPicker && widget.currencyIconPath.isNotEmpty) + TokenImageWidget( + imageUrl: widget.currencyIconPath, + size: 24, ), - ), - ], + Text(widget.currency), + if (widget.hasPicker) + CakeImageWidget( + imageUrl: "assets/new-ui/chooser.svg", + width: 12, + height: 12, + colorFilter: ColorFilter.mode( + Theme.of(context).colorScheme.primary, + BlendMode.srcIn, + ), + ), + ], + ), ), ), ), @@ -155,9 +178,15 @@ class _NewSendAmountInputState extends State { if (state.hasError) Padding( padding: const EdgeInsets.only(top: 6, left: 8), - child: Text( - state.errorText!, - style: TextStyle(fontSize: 12, color: Theme.of(context).colorScheme.error), + child: Semantics( + container: true, + liveRegion: true, + label: "${S.of(context).amount}${state.errorText!}", + excludeSemantics: true, + child: Text( + state.errorText!, + style: TextStyle(fontSize: 12, color: Theme.of(context).colorScheme.error), + ), ), ), ], diff --git a/lib/new-ui/widgets/send_page/send_confirm_bottom_widget.dart b/lib/new-ui/widgets/send_page/send_confirm_bottom_widget.dart index 540910b3e..950932e9a 100644 --- a/lib/new-ui/widgets/send_page/send_confirm_bottom_widget.dart +++ b/lib/new-ui/widgets/send_page/send_confirm_bottom_widget.dart @@ -80,18 +80,25 @@ class LoadingBottomWidget extends StatelessWidget { @override Widget build(BuildContext context) { - return Row( - mainAxisAlignment: MainAxisAlignment.center, - spacing: 12, - children: [ - CupertinoActivityIndicator( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - Text( - text, - style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant), - ) - ], + // One node per state so each step of the send is announced exactly once. + return Semantics( + container: true, + liveRegion: true, + label: text, + excludeSemantics: true, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + spacing: 12, + children: [ + CupertinoActivityIndicator( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + Text( + text, + style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant), + ) + ], + ), ); } } @@ -106,45 +113,51 @@ class TransactionErrorActions extends StatelessWidget { return Column( spacing: 12, children: [ - Container( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.errorContainer.withAlpha(64), - borderRadius: BorderRadius.circular(16), - ), - child: Padding( - padding: const EdgeInsets.all(12.0), - child: Column( - spacing: 12, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.center, - spacing: 8, - children: [ - CakeImageWidget( - imageUrl: "assets/new-ui/warning.svg", - height: 24, - width: 24, - colorFilter: - ColorFilter.mode(Theme.of(context).colorScheme.error, BlendMode.srcIn), - ), - Text( - S.of(context).transaction_error, - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w400, - color: Theme.of(context).colorScheme.error), - ) - ], - ), - Text( - errorText, - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w400, - color: Theme.of(context).colorScheme.error), - textAlign: TextAlign.center, - ), - ], + Semantics( + container: true, + liveRegion: true, + label: "${S.of(context).transaction_error}\n$errorText", + excludeSemantics: true, + child: Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.errorContainer.withAlpha(64), + borderRadius: BorderRadius.circular(16), + ), + child: Padding( + padding: const EdgeInsets.all(12.0), + child: Column( + spacing: 12, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.center, + spacing: 8, + children: [ + CakeImageWidget( + imageUrl: "assets/new-ui/warning.svg", + height: 24, + width: 24, + colorFilter: + ColorFilter.mode(Theme.of(context).colorScheme.error, BlendMode.srcIn), + ), + Text( + S.of(context).transaction_error, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w400, + color: Theme.of(context).colorScheme.error), + ) + ], + ), + Text( + errorText, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w400, + color: Theme.of(context).colorScheme.error), + textAlign: TextAlign.center, + ), + ], + ), ), ), ), diff --git a/lib/new-ui/widgets/send_page/send_confirm_sheet.dart b/lib/new-ui/widgets/send_page/send_confirm_sheet.dart index dccaf41ba..5b34686d3 100644 --- a/lib/new-ui/widgets/send_page/send_confirm_sheet.dart +++ b/lib/new-ui/widgets/send_page/send_confirm_sheet.dart @@ -87,11 +87,15 @@ class _SendConfirmSheetState extends State { offset: _committed ? const Offset(-1, 0) : Offset.zero, duration: const Duration(milliseconds: 300), curve: Curves.easeOutCubic, - child: SendTransactionDetails( - sendViewModel: widget.sendViewModel, - isPage: widget.isPage, - title: widget.title, - iconPath: widget.iconPath, + // Both screens stay mounted; only the visible one may be reachable. + child: ExcludeSemantics( + excluding: _committed, + child: SendTransactionDetails( + sendViewModel: widget.sendViewModel, + isPage: widget.isPage, + title: widget.title, + iconPath: widget.iconPath, + ), ), ), ), @@ -102,8 +106,11 @@ class _SendConfirmSheetState extends State { offset: _committed ? Offset.zero : const Offset(1, 0), duration: const Duration(milliseconds: 300), curve: Curves.easeOutCubic, - child: TransactionCommitedScreen( - sendViewModel: widget.sendViewModel, + child: ExcludeSemantics( + excluding: !_committed, + child: TransactionCommitedScreen( + sendViewModel: widget.sendViewModel, + ), ), ), ), @@ -154,9 +161,15 @@ class SendTransactionDetails extends StatelessWidget { width: 28, height: 28, ), - Text( - title ?? S.of(context).send, - style: TextStyle(fontWeight: FontWeight.w500, fontSize: 20), + Semantics( + header: true, + // Android reads the heading from headingLevel since the + // Flutter 3.41 engine; header: alone only covers iOS. + headingLevel: 1, + child: Text( + title ?? S.of(context).send, + style: TextStyle(fontWeight: FontWeight.w500, fontSize: 20), + ), ) ], ), @@ -250,37 +263,40 @@ class SendTransactionDetails extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.start, spacing: 24, children: [ - Column( - children: [ - Row( - mainAxisSize: MainAxisSize.max, - mainAxisAlignment: MainAxisAlignment.center, - spacing: 4, - children: [ - Flexible( - child: Text( - amount, - style: TextStyle( - fontSize: 36, - fontWeight: FontWeight.w400, - color: Theme.of(context).colorScheme.onSurface), + // The amount being sent is the value under review: announce it as one group. + MergeSemantics( + child: Column( + children: [ + Row( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.center, + spacing: 4, + children: [ + Flexible( + child: Text( + amount, + style: TextStyle( + fontSize: 36, + fontWeight: FontWeight.w400, + color: Theme.of(context).colorScheme.onSurface), + ), ), - ), - Text(currencySymbol, - style: TextStyle( - fontSize: 36, - fontWeight: FontWeight.w400, - color: Theme.of(context).colorScheme.onSurfaceVariant)) - ], - ), - Text( - fiatAmount, - style: TextStyle( - fontSize: 20, - fontWeight: FontWeight.w500, - color: Theme.of(context).colorScheme.onSurfaceVariant), - ), - ], + Text(currencySymbol, + style: TextStyle( + fontSize: 36, + fontWeight: FontWeight.w400, + color: Theme.of(context).colorScheme.onSurfaceVariant)) + ], + ), + Text( + fiatAmount, + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.w500, + color: Theme.of(context).colorScheme.onSurfaceVariant), + ), + ], + ), ), if (outputs.length >= 1 && (outputs.first.extractedAddress.isNotEmpty || outputs.first.address.isNotEmpty) && @@ -352,32 +368,34 @@ class SendTransactionDetails extends StatelessWidget { children: [ Padding( padding: const EdgeInsets.all(12.0), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text(S.of(context).fee, - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w400, - color: Theme.of(context).colorScheme.onSurface)), - Column( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Text( - "${fee.withLocalSeperator(sendViewModel.languageCode)} ${sendViewModel.currencySymbol}", + child: MergeSemantics( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(S.of(context).fee, style: TextStyle( fontSize: 14, fontWeight: FontWeight.w400, - color: Theme.of(context).colorScheme.onSurfaceVariant), - ), - Text(fiatFee.withLocalSeperator(sendViewModel.languageCode), + color: Theme.of(context).colorScheme.onSurface)), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + "${fee.withLocalSeperator(sendViewModel.languageCode)} ${sendViewModel.currencySymbol}", style: TextStyle( fontSize: 14, fontWeight: FontWeight.w400, - color: Theme.of(context).colorScheme.onSurfaceVariant)) - ], - ) - ], + color: Theme.of(context).colorScheme.onSurfaceVariant), + ), + Text(fiatFee.withLocalSeperator(sendViewModel.languageCode), + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w400, + color: Theme.of(context).colorScheme.onSurfaceVariant)) + ], + ) + ], + ), ), ), if (sendViewModel.isElectrumWallet) ...[ @@ -390,27 +408,29 @@ class SendTransactionDetails extends StatelessWidget { ), Padding( padding: const EdgeInsets.all(12.0), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text(S.of(context).network, - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w400, - color: Theme.of(context).colorScheme.onSurface)), - Column( - children: [ - Text( - sendViewModel.selectedCryptoCurrency == CryptoCurrency.btcln - ? "Lightning" - : bitcoin!.getNetworkName(sendViewModel.wallet), - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w400, - color: Theme.of(context).colorScheme.onSurfaceVariant)) - ], - ) - ], + child: MergeSemantics( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(S.of(context).network, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w400, + color: Theme.of(context).colorScheme.onSurface)), + Column( + children: [ + Text( + sendViewModel.selectedCryptoCurrency == CryptoCurrency.btcln + ? "Lightning" + : bitcoin!.getNetworkName(sendViewModel.wallet), + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w400, + color: Theme.of(context).colorScheme.onSurfaceVariant)) + ], + ) + ], + ), ), ) ], @@ -459,9 +479,14 @@ class _TransactionCommitedScreenState extends State { SizedBox( height: 12, ), - Text( - S.of(context).transaction_sent_new, - style: TextStyle(fontSize: 24, fontWeight: FontWeight.w600), + // The sheet swaps its content in place, so this title becoming visible is what + // tells a screen reader that the transaction went through. + Semantics( + liveRegion: true, + child: Text( + S.of(context).transaction_sent_new, + style: TextStyle(fontSize: 24, fontWeight: FontWeight.w600), + ), ), SizedBox(), CakeImageWidget(width: 200, height: 200, imageUrl: "assets/new-ui/birthday_cake.svg"), @@ -552,37 +577,44 @@ class TransactionCommittedScreenActionButton extends StatelessWidget { @override Widget build(BuildContext context) { return Flexible( - child: GestureDetector( - onTap: onTap, - child: Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(16), - color: Theme.of(context).colorScheme.surfaceContainer), - child: Padding( - padding: EdgeInsets.all(16), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - spacing: 10, - children: [ - isLoading - ? CupertinoActivityIndicator() - : CakeImageWidget( - imageUrl: iconPath, - width: 24, - height: 24, - colorFilter: ColorFilter.mode( - Theme.of(context).colorScheme.primary, BlendMode.srcIn), - ), - Text( - text, - style: TextStyle( - color: Theme.of(context).colorScheme.primary, - fontWeight: FontWeight.w500), - ) - ], - ), - ), - ))); + child: Semantics( + button: true, + enabled: !isLoading, + label: text, + value: isLoading ? S.of(context).loading : null, + onTap: isLoading ? null : onTap, + excludeSemantics: true, + child: GestureDetector( + onTap: isLoading ? null : onTap, + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + color: Theme.of(context).colorScheme.surfaceContainer), + child: Padding( + padding: EdgeInsets.all(16), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + spacing: 10, + children: [ + isLoading + ? CupertinoActivityIndicator() + : CakeImageWidget( + imageUrl: iconPath, + width: 24, + height: 24, + colorFilter: ColorFilter.mode( + Theme.of(context).colorScheme.primary, BlendMode.srcIn), + ), + Text( + text, + style: TextStyle( + color: Theme.of(context).colorScheme.primary, + fontWeight: FontWeight.w500), + ) + ], + ), + ), + )))); } } @@ -623,16 +655,28 @@ class _MultiSendAddressPreviewState extends State { style: TextStyle(fontFamily: "IBM Plex Mono"), ), if (!_expanded) - GestureDetector( - onTap: () { - setState(() { - _expanded = true; - }); - }, - child: Text(middleTruncate(widget.address, 8, 8), - style: TextStyle( - fontFamily: "IBM Plex Mono", - color: Theme.of(context).colorScheme.primary))) + Semantics( + button: true, + // Read the whole address rather than the truncated form. + label: widget.address, + hint: S.of(context).show_full_address, + onTap: () { + setState(() { + _expanded = true; + }); + }, + excludeSemantics: true, + child: GestureDetector( + onTap: () { + setState(() { + _expanded = true; + }); + }, + child: Text(middleTruncate(widget.address, 8, 8), + style: TextStyle( + fontFamily: "IBM Plex Mono", + color: Theme.of(context).colorScheme.primary))), + ) else AddressFormatter.buildSegmentedAddress( address: widget.address, 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 d3f408223..5dc69491a 100644 --- a/lib/new-ui/widgets/send_page/send_memo_input.dart +++ b/lib/new-ui/widgets/send_page/send_memo_input.dart @@ -31,11 +31,16 @@ class NewSendMemoInput extends StatelessWidget { child: Row( children: [ Expanded( - child: TextField( - maxLength: maxMemoLength, - controller: memoController, - decoration: InputDecoration( - hintText: hintText ?? S.of(context).memo_optional, counterText: ""), + child: MergeSemantics( + child: Semantics( + label: hintText ?? S.of(context).memo_optional, + child: TextField( + maxLength: maxMemoLength, + controller: memoController, + decoration: InputDecoration( + hintText: hintText ?? S.of(context).memo_optional, counterText: ""), + ), + ), ), ), SizedBox(width: 12), @@ -61,8 +66,12 @@ class NewSendMemoInput extends StatelessWidget { style: TextStyle( fontSize: 12, color: Theme.of(context).colorScheme.onSurfaceVariant)), ), - Text("${memoController.text.length} / ${maxMemoLength}", - style: TextStyle(fontSize: 12, color: Theme.of(context).colorScheme.primary)) + // The field itself already reports the character count through + // maxValueLength/currentValueLength, so this visual counter stays silent. + ExcludeSemantics( + child: Text("${memoController.text.length} / ${maxMemoLength}", + style: TextStyle(fontSize: 12, color: Theme.of(context).colorScheme.primary)), + ) ], ), ) diff --git a/lib/new-ui/widgets/send_page/send_syncing_indicator.dart b/lib/new-ui/widgets/send_page/send_syncing_indicator.dart index 946efa116..516e071ee 100644 --- a/lib/new-ui/widgets/send_page/send_syncing_indicator.dart +++ b/lib/new-ui/widgets/send_page/send_syncing_indicator.dart @@ -1,3 +1,4 @@ +import 'package:cake_wallet/generated/i18n.dart'; import 'package:cw_core/sync_status.dart'; import 'package:flutter/material.dart'; @@ -18,28 +19,40 @@ class SendSyncingIndicator extends StatelessWidget { eta = ""; } - return Container( - height: 48, - decoration: BoxDecoration(color: backgroundColor, borderRadius: BorderRadius.circular(99999)), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 12.0), - child: Row( - mainAxisSize: MainAxisSize.min, - spacing: 10, - children: [ - if (status.progress() > 0) - SizedBox( - width: 24, - height: 24, - child: CircularProgressIndicator( - value: status.progress(), - strokeWidth: 3, - strokeCap: StrokeCap.round, - color: outlineColor, + final statusText = "${S.of(context).synchronizing}... ${eta}"; + + // A single status node: progress ticks keep updating the value, and without a live + // region they do not interrupt the user on every tick. + return Semantics( + container: true, + label: statusText, + value: status.progress() > 0 ? "${(status.progress() * 100).round()}%" : null, + excludeSemantics: true, + child: Container( + height: 48, + decoration: + BoxDecoration(color: backgroundColor, borderRadius: BorderRadius.circular(99999)), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12.0), + child: Row( + mainAxisSize: MainAxisSize.min, + spacing: 10, + children: [ + if (status.progress() > 0) + SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator( + value: status.progress(), + strokeWidth: 3, + strokeCap: StrokeCap.round, + color: outlineColor, + semanticsLabel: S.of(context).synchronizing, + ), ), - ), - Text("Synchronizing... ${eta}", style: TextStyle(color: outlineColor)), - ], + Text(statusText, style: TextStyle(color: outlineColor)), + ], + ), ), ), ); diff --git a/lib/src/widgets/primary_button.dart b/lib/src/widgets/primary_button.dart index 07915f867..48d00ba97 100644 --- a/lib/src/widgets/primary_button.dart +++ b/lib/src/widgets/primary_button.dart @@ -1,3 +1,4 @@ +import 'package:cake_wallet/generated/i18n.dart'; import 'package:cake_wallet/utils/responsive_layout_util.dart'; import 'package:dotted_border/dotted_border.dart'; import 'package:flutter/cupertino.dart'; @@ -92,35 +93,53 @@ class LoadingPrimaryButton extends StatelessWidget { @override Widget build(BuildContext context) { - return ConstrainedBox( - constraints: BoxConstraints(maxWidth: ResponsiveLayoutUtilBase.kDesktopMaxWidthConstraint), - child: SizedBox( - width: double.infinity, - height: 52.0, - child: TextButton( - onPressed: (isLoading || isDisabled) - ? null - : () { - FocusScope.of(context).unfocus(); - onPressed.call(); - }, - style: ButtonStyle( - backgroundColor: WidgetStateProperty.all(isDisabled ? color.withOpacity(0.5) : color), - shape: WidgetStateProperty.all( - RoundedRectangleBorder( - borderRadius: BorderRadius.circular(18.0), - ), - )), - child: isLoading - ? CupertinoActivityIndicator(animating: true, color: textColor) - : Text( - text, - style: Theme.of(context).textTheme.bodyMedium!.copyWith( - fontSize: 15.0, - fontWeight: FontWeight.w600, - color: isDisabled ? textColor.withOpacity(0.5) : textColor, - ), - ), + final isEnabled = !isLoading && !isDisabled; + + // The spinner replaces the label while loading, so the name is kept on a single + // semantics node that also reports the loading and disabled states. + return Semantics( + button: true, + enabled: isEnabled, + label: text, + value: isLoading ? S.of(context).loading : null, + onTap: isEnabled + ? () { + FocusScope.of(context).unfocus(); + onPressed.call(); + } + : null, + excludeSemantics: true, + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: ResponsiveLayoutUtilBase.kDesktopMaxWidthConstraint), + child: SizedBox( + width: double.infinity, + height: 52.0, + child: TextButton( + onPressed: (isLoading || isDisabled) + ? null + : () { + FocusScope.of(context).unfocus(); + onPressed.call(); + }, + style: ButtonStyle( + backgroundColor: + WidgetStateProperty.all(isDisabled ? color.withOpacity(0.5) : color), + shape: WidgetStateProperty.all( + RoundedRectangleBorder( + borderRadius: BorderRadius.circular(18.0), + ), + )), + child: isLoading + ? CupertinoActivityIndicator(animating: true, color: textColor) + : Text( + text, + style: Theme.of(context).textTheme.bodyMedium!.copyWith( + fontSize: 15.0, + fontWeight: FontWeight.w600, + color: isDisabled ? textColor.withOpacity(0.5) : textColor, + ), + ), + ), ), ), ); diff --git a/res/values/strings_en.arb b/res/values/strings_en.arb index dc35bd152..d81f3dd52 100644 --- a/res/values/strings_en.arb +++ b/res/values/strings_en.arb @@ -733,6 +733,7 @@ "onramper_option_description": "Quickly buy crypto with many payment methods. Available in most countries. Spreads and fees vary.", "open_gift_card": "Open Gift Card", "open_wallet": "Open Wallet", + "opens_in_browser": "Opens in browser", "opens_externally": "Opens externally", "optional_description": "Optional description", "optional_email_hint": "Optional payee notification email", @@ -845,6 +846,7 @@ "qr_code_format_note": "Different QR code formats are used by different hardware wallets in the Bitcoin space", "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", "qr_payment_amount": "This QR code contains a payment amount. Do you want to overwrite the current value?", "quantity": "Quantity", "question_to_disable_2fa": "Are you sure that you want to disable Cake 2FA? A 2FA code will no longer be needed to access the wallet and certain functions.", @@ -856,6 +858,7 @@ "receiving": "Receiving", "recieving": "Recieving", "recipient_address": "Recipient address", + "recipient_number": "Recipient ${number}", "reconnect": "Reconnect", "reconnect_alert_text": "Are you sure you want to reconnect?", "reconnect_your_hardware_wallet": "Reconnect your Hardware Wallet", @@ -1094,6 +1097,7 @@ "show_balance_toast": "Long press to hide or show balance", "show_combined_balance": "Show combined balance", "show_details": "Show Details", + "show_full_address": "Show full address", "show_hidden_addresses": "Show hidden addresses", "show_keys": "Show seed/keys", "show_market_place": "Show Marketplace",