diff --git a/lib/new-ui/pages/addresses_page.dart b/lib/new-ui/pages/addresses_page.dart index e75dc16fe..ee02aa88f 100644 --- a/lib/new-ui/pages/addresses_page.dart +++ b/lib/new-ui/pages/addresses_page.dart @@ -20,6 +20,7 @@ import 'package:cake_wallet/wownero/wownero.dart'; import 'package:cw_core/card_design.dart'; import 'package:cw_core/wallet_type.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/semantics.dart'; import 'package:flutter_mobx/flutter_mobx.dart'; import 'package:cake_wallet/utils/list_item.dart'; @@ -213,24 +214,31 @@ class AddressSearchBox extends StatelessWidget { border: Border.all( color: Theme.of(context).colorScheme.surfaceContainerHighest, width: 1), borderRadius: BorderRadius.circular(99999)), - child: TextField( - controller: controller, - decoration: InputDecoration( - hintText: S.of(context).search, - hintStyle: TextStyle( - fontWeight: FontWeight.w600, + // The hint names the field only while it is empty, so the label is + // supplied once there is text to keep exactly one announcement. + child: MergeSemantics( + child: Semantics( + label: controller.text.isEmpty ? null : S.of(context).search, + child: TextField( + controller: controller, + decoration: InputDecoration( + hintText: S.of(context).search, + hintStyle: TextStyle( + fontWeight: FontWeight.w600, + ), + prefixIcon: ExcludeSemantics(child: Icon(Icons.search)), + filled: false, + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(99999), + borderSide: BorderSide.none, + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(99999), + borderSide: BorderSide.none, + ), + contentPadding: EdgeInsets.symmetric(vertical: 8), + ), ), - prefixIcon: Icon(Icons.search), - filled: false, - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(99999), - borderSide: BorderSide.none, - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(99999), - borderSide: BorderSide.none, - ), - contentPadding: EdgeInsets.symmetric(vertical: 8), ), ), ), @@ -334,116 +342,139 @@ class AddressRow extends StatelessWidget { @override Widget build(BuildContext context) { final hasLabel = item.name != null && item.name!.isNotEmpty; + final hideLabel = item.isHidden ? S.of(context).unhide_address : S.of(context).hide_address; - return GestureDetector( - onTap: onSelect, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: LongPressPopupBuilder( - popup: LongPressMenu( - items: [ - LongPressMenuItem( - label: S.of(context).set_label, - iconPath: "assets/new-ui/address_set_label.svg", - onSelected: () async { - Navigator.of(context, rootNavigator: true).pop(); - final res = await showPopUp( - context: context, - builder: (context) => getIt.get(param1: item)); + Future editLabel() async { + final res = await showPopUp( + context: context, builder: (context) => getIt.get(param1: item)); - if (res != null) { - onLabelChanged(); - } - }), - LongPressMenuItem( - label: item.isHidden ? S.of(context).unhide_address : S.of(context).hide_address, - iconPath: "assets/new-ui/address_hide.svg", - onSelected: () { - Navigator.of(context, rootNavigator: true).pop(); - onAddressHidden(); - }, - color: Theme.of(context).colorScheme.error), - LongPressMenuItem( - label: 'Info', - iconPath: "assets/images/info_icon.svg", - onSelected: () async { - Navigator.of(context, rootNavigator: true).pop(); - await showPopUp( - context: context, - builder: (context) => getIt.get(param1: item)); - }), - ], - ), - child: AnimatedContainer( - duration: Duration(milliseconds: 150), - decoration: BoxDecoration( - color: selected - ? Theme.of(context).colorScheme.surfaceContainerHigh - : Theme.of(context).colorScheme.surfaceContainer, - borderRadius: BorderRadius.vertical( - top: Radius.circular(first ? 16 : 0), - bottom: Radius.circular(last ? 16 : 0), + if (res != null) { + onLabelChanged(); + } + } + + Future showInfo() async { + await showPopUp( + context: context, builder: (context) => getIt.get(param1: item)); + } + + // The row is a single control: selection on tap, and the actions that are + // otherwise reachable only by long press exposed as custom actions. + return MergeSemantics( + child: Semantics( + button: true, + selected: selected, + customSemanticsActions: { + CustomSemanticsAction(label: S.of(context).set_label): editLabel, + CustomSemanticsAction(label: hideLabel): onAddressHidden, + CustomSemanticsAction(label: S.of(context).info): showInfo, + }, + child: GestureDetector( + onTap: onSelect, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: LongPressPopupBuilder( + popup: LongPressMenu( + items: [ + LongPressMenuItem( + label: S.of(context).set_label, + iconPath: "assets/new-ui/address_set_label.svg", + onSelected: () { + Navigator.of(context, rootNavigator: true).pop(); + editLabel(); + }), + LongPressMenuItem( + label: hideLabel, + iconPath: "assets/new-ui/address_hide.svg", + onSelected: () { + Navigator.of(context, rootNavigator: true).pop(); + onAddressHidden(); + }, + color: Theme.of(context).colorScheme.error), + LongPressMenuItem( + label: S.of(context).info, + iconPath: "assets/images/info_icon.svg", + onSelected: () { + Navigator.of(context, rootNavigator: true).pop(); + showInfo(); + }), + ], ), - ), - child: Padding( - padding: const EdgeInsets.all(12.0), - child: Row( - spacing: 8, - mainAxisSize: MainAxisSize.max, - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Expanded( - child: Column( - children: [ - Row( - spacing: 4, + child: AnimatedContainer( + duration: Duration(milliseconds: 150), + decoration: BoxDecoration( + color: selected + ? Theme.of(context).colorScheme.surfaceContainerHigh + : Theme.of(context).colorScheme.surfaceContainer, + borderRadius: BorderRadius.vertical( + top: Radius.circular(first ? 16 : 0), + bottom: Radius.circular(last ? 16 : 0), + ), + ), + child: Padding( + padding: const EdgeInsets.all(12.0), + child: Row( + spacing: 8, + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Column( children: [ - if (hasLabel) - Text( - item.name!, - style: TextStyle( - fontSize: 12, - color: Theme.of(context).colorScheme.onSurface, - fontWeight: FontWeight.w500), - ), - Expanded( - child: AddressFormatter.buildSegmentedAddress( - address: item.address, - walletType: walletType, - textAlign: TextAlign.left, - evenTextStyle: Theme.of(context).textTheme.bodyMedium!.copyWith( + Row( + spacing: 4, + children: [ + if (hasLabel) + Text( + item.name!, + style: TextStyle( fontSize: 12, - fontWeight: FontWeight.w400, - color: hasLabel - ? Theme.of(context).colorScheme.onSurfaceVariant - : Theme.of(context).colorScheme.onSurface, - ), - shouldTruncate: (hasLabel)), + color: Theme.of(context).colorScheme.onSurface, + fontWeight: FontWeight.w500), + ), + Expanded( + child: AddressFormatter.buildSegmentedAddress( + address: item.address, + walletType: walletType, + textAlign: TextAlign.left, + evenTextStyle: Theme.of(context).textTheme.bodyMedium!.copyWith( + fontSize: 12, + fontWeight: FontWeight.w400, + color: hasLabel + ? Theme.of(context).colorScheme.onSurfaceVariant + : Theme.of(context).colorScheme.onSurface, + ), + shouldTruncate: (hasLabel)), + ), + ], ), + Row( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + "${S.of(context).transactions}: ${item.txCount}", + style: TextStyle( + fontSize: 12, + color: Theme.of(context).colorScheme.onSurfaceVariant), + ), + Text( + "${hasReceived ? S.of(context).received : S.of(context).balance}: ${item.balance}", + style: TextStyle( + fontSize: 12, + color: Theme.of(context).colorScheme.onSurfaceVariant)), + ], + ) ], ), - Row( - mainAxisSize: MainAxisSize.max, - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - "${S.of(context).transactions}: ${item.txCount}", - style: TextStyle( - fontSize: 12, - color: Theme.of(context).colorScheme.onSurfaceVariant), - ), - Text( - "${hasReceived ? S.of(context).received : S.of(context).balance}: ${item.balance}", - style: TextStyle( - fontSize: 12, - color: Theme.of(context).colorScheme.onSurfaceVariant)), - ], - ) - ], - ), + ), + // The row's selected state carries this information. + if (selected) + ExcludeSemantics( + child: CakeImageWidget(imageUrl: "assets/new-ui/checkmark.svg")) + ], ), - if (selected) CakeImageWidget(imageUrl: "assets/new-ui/checkmark.svg") - ], + ), ), ), ), @@ -463,26 +494,34 @@ class ShowHiddenButton extends StatelessWidget { child: Column( children: [ Material( - child: InkWell( - onTap: () { - Navigator.of(context).pushNamed(Routes.receiveAddresses, arguments: true); - }, - child: Container( - height: 48, - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainer, - borderRadius: BorderRadius.vertical(top: Radius.circular(16)), - ), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text(S.of(context).show_hidden_addresses), - RotatedBox( - quarterTurns: 1, - child: CakeImageWidget(imageUrl: "assets/new-ui/dropdown_arrow.svg")) - ], + child: MergeSemantics( + child: Semantics( + button: true, + child: InkWell( + onTap: () { + Navigator.of(context).pushNamed(Routes.receiveAddresses, arguments: true); + }, + child: Container( + height: 48, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainer, + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(S.of(context).show_hidden_addresses), + ExcludeSemantics( + child: RotatedBox( + quarterTurns: 1, + child: + CakeImageWidget(imageUrl: "assets/new-ui/dropdown_arrow.svg")), + ) + ], + ), + ), ), ), ), diff --git a/lib/new-ui/pages/receive_page.dart b/lib/new-ui/pages/receive_page.dart index e68a09305..b5656e9de 100644 --- a/lib/new-ui/pages/receive_page.dart +++ b/lib/new-ui/pages/receive_page.dart @@ -275,11 +275,23 @@ class _NewReceivePageState extends State { ReceiveAddressWidget( addressListViewModel: widget.addressListViewModel, ), - GestureDetector( - onTap: _showLabelModal, - child: ReceiveLabelWidget( - name: _addressItemWithLabel?.name ?? "", - largeQrMode: _largeQrMode, + // The label chip animates to zero height when there is no + // label (or in large QR mode); keep it out of the semantics + // tree entirely while it is collapsed. + ExcludeSemantics( + excluding: _largeQrMode || !hasLabel, + child: MergeSemantics( + child: Semantics( + button: true, + hint: S.of(context).set_label, + child: GestureDetector( + onTap: _showLabelModal, + child: ReceiveLabelWidget( + name: _addressItemWithLabel?.name ?? "", + largeQrMode: _largeQrMode, + ), + ), + ), ), ), Observer( diff --git a/lib/new-ui/widgets/copy_wrapper.dart b/lib/new-ui/widgets/copy_wrapper.dart index 0f3255c48..3f9011ab4 100644 --- a/lib/new-ui/widgets/copy_wrapper.dart +++ b/lib/new-ui/widgets/copy_wrapper.dart @@ -1,8 +1,10 @@ import 'dart:io'; +import 'package:cake_wallet/generated/i18n.dart'; import 'package:cake_wallet/utils/clipboard_util.dart'; import 'package:device_info_plus/device_info_plus.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/semantics.dart'; import 'package:flutter/services.dart'; class CopyWrapper extends StatefulWidget { @@ -11,13 +13,25 @@ class CopyWrapper extends StatefulWidget { this.data, this.requireLongPress = false, this.isSensitive = false, - required this.builder, - this.duration = const Duration(milliseconds: 1200)}); + this.builder, + this.controlBuilder, + this.duration = const Duration(milliseconds: 1200)}) + : assert((builder == null) != (controlBuilder == null), + "Provide exactly one of builder or controlBuilder"); final ClipboardData? data; final bool isSensitive; final bool requireLongPress; - final Widget Function(BuildContext, bool) builder; + + /// Builds content that this widget makes copyable with its own gesture + /// detector. Receives the transient "copied" state. + final Widget Function(BuildContext, bool)? builder; + + /// Builds a control that performs the copy itself, so no extra gesture + /// detector or actionable semantics node is stacked on top of it. [onCopy] is + /// null when there is nothing to copy. + final Widget Function(BuildContext context, bool copied, VoidCallback? onCopy)? controlBuilder; + final Duration duration; @override @@ -31,6 +45,11 @@ class _CopyWrapperState extends State { if (widget.data == null) return; ClipboardUtil.setSensitiveDataToClipboard(widget.data!, isSensitive: widget.isSensitive); HapticFeedback.mediumImpact(); + // Screen readers learn about the copy from the transient "copied" state + // below, which build() marks as a live region, instead of from a push + // announcement: those are deprecated on Android and make TalkBack drop + // whatever it was saying. Where we don't render that state, android 13+ + // already shows its own clipboard confirmation. if (await shouldShowCopied()) { setState(() => copied = true); Future.delayed(widget.duration, () { @@ -41,11 +60,41 @@ class _CopyWrapperState extends State { @override Widget build(BuildContext context) { - return GestureDetector( - behavior: HitTestBehavior.translucent, - onTap: widget.requireLongPress ? null : handleCopy, - onLongPress: !widget.requireLongPress ? null : handleCopy, - child: widget.builder(context, copied), + final VoidCallback? onCopy = widget.data == null ? null : handleCopy; + + // The control's own label carries the copied state (Copy -> Copied), so + // merging it into one node and marking that node a live region while copied + // is enough for a screen reader to pick the change up. + if (widget.controlBuilder != null) { + return MergeSemantics( + child: Semantics( + liveRegion: copied, + child: widget.controlBuilder!(context, copied, onCopy), + ), + ); + } + + final content = widget.builder!(context, copied); + + // Nothing to copy: don't leave behind a gesture detector that a screen + // reader would present as an actionable target that does nothing. + if (onCopy == null) return content; + + return MergeSemantics( + child: Semantics( + button: !widget.requireLongPress, + liveRegion: copied, + hint: widget.requireLongPress ? S.of(context).long_press_to_copy : S.of(context).copy, + customSemanticsActions: { + CustomSemanticsAction(label: S.of(context).copy): onCopy, + }, + child: GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: widget.requireLongPress ? null : onCopy, + onLongPress: !widget.requireLongPress ? null : onCopy, + child: content, + ), + ), ); } diff --git a/lib/new-ui/widgets/currency_picker/chain_chip_strip.dart b/lib/new-ui/widgets/currency_picker/chain_chip_strip.dart index 76b1727a2..17910a4c5 100644 --- a/lib/new-ui/widgets/currency_picker/chain_chip_strip.dart +++ b/lib/new-ui/widgets/currency_picker/chain_chip_strip.dart @@ -56,24 +56,31 @@ class _ChainChip extends StatelessWidget { final colors = Theme.of(context).colorScheme; return Padding( padding: const EdgeInsetsDirectional.only(end: 8), - child: InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(80), - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 10), - decoration: BoxDecoration( - color: isSelected ? colors.primary : colors.surfaceContainer, - borderRadius: BorderRadius.circular(20), - ), - child: Center( - child: Text( - label, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - fontWeight: FontWeight.w500, - fontSize: isSelected ? 14 : 12, - letterSpacing: -0.07, - color: isSelected ? colors.onPrimary : colors.primary, - ), + child: MergeSemantics( + child: Semantics( + button: true, + selected: isSelected, + inMutuallyExclusiveGroup: true, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(80), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10), + decoration: BoxDecoration( + color: isSelected ? colors.primary : colors.surfaceContainer, + borderRadius: BorderRadius.circular(20), + ), + child: Center( + child: Text( + label, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w500, + fontSize: isSelected ? 14 : 12, + letterSpacing: -0.07, + color: isSelected ? colors.onPrimary : colors.primary, + ), + ), + ), ), ), ), diff --git a/lib/new-ui/widgets/currency_picker/currency_picker_row.dart b/lib/new-ui/widgets/currency_picker/currency_picker_row.dart index 54d712200..52ac4c065 100644 --- a/lib/new-ui/widgets/currency_picker/currency_picker_row.dart +++ b/lib/new-ui/widgets/currency_picker/currency_picker_row.dart @@ -24,55 +24,65 @@ class CurrencyPickerRow extends StatelessWidget { @override Widget build(BuildContext context) { final colors = Theme.of(context).colorScheme; - return InkWell( - onTap: onTap, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), - child: Row( - children: [ - _IconWithBadge(currency: currency, badgePath: chainBadgePath), - const SizedBox(width: 12), - Expanded( - child: Row( - children: [ - Flexible( - child: Text( - currency.fullName ?? currency.title, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - fontWeight: FontWeight.w500, + return MergeSemantics( + child: Semantics( + button: true, + selected: isSelected, + child: InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), + child: Row( + children: [ + ExcludeSemantics( + child: _IconWithBadge(currency: currency, badgePath: chainBadgePath), + ), + const SizedBox(width: 12), + Expanded( + child: Row( + children: [ + Flexible( + child: Text( + currency.fullName ?? currency.title, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w500, + ), + ), + ), + if (chainPillLabel != null) ...[ + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: colors.surfaceContainerHigh, + borderRadius: BorderRadius.circular(10), ), - ), + child: Text( + chainPillLabel!, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + fontWeight: FontWeight.w500, + color: colors.onSecondaryContainer, + ), + ), + ), + ], + ], ), - if (chainPillLabel != null) ...[ - const SizedBox(width: 8), - Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), - decoration: BoxDecoration( - color: colors.surfaceContainerHigh, - borderRadius: BorderRadius.circular(10), - ), - child: Text( - chainPillLabel!, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - fontWeight: FontWeight.w500, - color: colors.onSecondaryContainer, - ), - ), - ), - ], - ], - ), + ), + const SizedBox(width: 8), + trailing, + const SizedBox(width: 8), + ExcludeSemantics( + child: Icon( + Icons.chevron_right, + size: 20, + color: isSelected ? colors.primary : colors.onSurfaceVariant, + ), + ), + ], ), - const SizedBox(width: 8), - trailing, - const SizedBox(width: 8), - Icon( - Icons.chevron_right, - size: 20, - color: isSelected ? colors.primary : colors.onSurfaceVariant, - ), - ], + ), ), ), ); diff --git a/lib/new-ui/widgets/currency_picker/currency_picker_search_field.dart b/lib/new-ui/widgets/currency_picker/currency_picker_search_field.dart index 8a62c35a7..25876f0f1 100644 --- a/lib/new-ui/widgets/currency_picker/currency_picker_search_field.dart +++ b/lib/new-ui/widgets/currency_picker/currency_picker_search_field.dart @@ -1,3 +1,4 @@ +import 'package:cake_wallet/generated/i18n.dart'; import 'package:cake_wallet/new-ui/widgets/currency_picker/currency_picker_args.dart'; import 'package:cw_core/crypto_currency.dart'; import 'package:flutter/material.dart'; @@ -27,30 +28,41 @@ class CurrencyPickerSearchField extends StatelessWidget { ), child: Row( children: [ - Icon(Icons.search, size: 20, color: colors.primary), + ExcludeSemantics(child: Icon(Icons.search, size: 20, color: colors.primary)), const SizedBox(width: 10), Expanded( - child: TextFormField( - controller: controller, - style: Theme.of(context).textTheme.bodyMedium, - decoration: InputDecoration( - isCollapsed: true, - border: InputBorder.none, - focusedBorder: InputBorder.none, - hintText: hintText, - hintStyle: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: colors.onSurfaceVariant, - ), + // The hint names the field only while it is empty, so the label is + // supplied once there is text to keep exactly one announcement. + child: MergeSemantics( + child: Semantics( + label: controller.text.isEmpty ? null : hintText, + child: TextFormField( + controller: controller, + style: Theme.of(context).textTheme.bodyMedium, + decoration: InputDecoration( + isCollapsed: true, + border: InputBorder.none, + focusedBorder: InputBorder.none, + hintText: hintText, + hintStyle: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colors.onSurfaceVariant, + ), + ), + ), ), ), ), if (controller.text.isNotEmpty) - InkWell( - onTap: controller.clear, - customBorder: const CircleBorder(), - child: Padding( - padding: const EdgeInsets.all(4), - child: Icon(Icons.close, size: 16, color: colors.onSurfaceVariant), + Semantics( + button: true, + label: S.of(context).clear, + child: InkWell( + onTap: controller.clear, + customBorder: const CircleBorder(), + child: Padding( + padding: const EdgeInsets.all(4), + child: Icon(Icons.close, size: 16, color: colors.onSurfaceVariant), + ), ), ), ], diff --git a/lib/new-ui/widgets/currency_picker/select_network_page.dart b/lib/new-ui/widgets/currency_picker/select_network_page.dart index 29f18af48..b395ec08c 100644 --- a/lib/new-ui/widgets/currency_picker/select_network_page.dart +++ b/lib/new-ui/widgets/currency_picker/select_network_page.dart @@ -39,11 +39,14 @@ class SelectNetworkPage extends StatelessWidget { onLeadingPressed: () => Navigator.of(context).maybePop(), ), const SizedBox(height: 24), - CakeImageWidget( - imageUrl: assetIconPath, - width: 75, - height: 75, - fit: BoxFit.cover, + // Illustration of the asset named by the title below it. + ExcludeSemantics( + child: CakeImageWidget( + imageUrl: assetIconPath, + width: 75, + height: 75, + fit: BoxFit.cover, + ), ), const SizedBox(height: 12), Text( @@ -107,33 +110,42 @@ class _NetworkRow extends StatelessWidget { final networkIconPath = wt != null ? getCryptoCurrencyIconForWalletListItem(wt) : variant.iconPath; final networkName = chainNameForCurrency(variant); - return InkWell( - onTap: onTap, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14), - child: Row( - children: [ - CakeImageWidget( - imageUrl: networkIconPath, - width: 28, - height: 28, - fit: BoxFit.cover, + return MergeSemantics( + child: Semantics( + button: true, + child: InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14), + child: Row( + children: [ + ExcludeSemantics( + child: CakeImageWidget( + imageUrl: networkIconPath, + width: 28, + height: 28, + fit: BoxFit.cover, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + networkName, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w500, + ), + ), + ), + ExcludeSemantics( + child: Icon( + Icons.chevron_right, + size: 20, + color: colors.onSurfaceVariant, + ), + ), + ], ), - const SizedBox(width: 12), - Expanded( - child: Text( - networkName, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - fontWeight: FontWeight.w500, - ), - ), - ), - Icon( - Icons.chevron_right, - size: 20, - color: colors.onSurfaceVariant, - ), - ], + ), ), ), ); diff --git a/lib/new-ui/widgets/receive_page/payjoin_copy_modal.dart b/lib/new-ui/widgets/receive_page/payjoin_copy_modal.dart index 8fd7c1730..435417330 100644 --- a/lib/new-ui/widgets/receive_page/payjoin_copy_modal.dart +++ b/lib/new-ui/widgets/receive_page/payjoin_copy_modal.dart @@ -4,6 +4,7 @@ import 'package:cake_wallet/new-ui/widgets/receive_page/receive_top_bar.dart'; import 'package:cake_wallet/src/widgets/new_list_row/new_list_section.dart'; import 'package:cw_core/payment_uris.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/semantics.dart'; import 'package:flutter/services.dart'; class PayjoinCopyModal extends StatelessWidget { @@ -33,7 +34,7 @@ class PayjoinCopyModal extends StatelessWidget { "": [ ListItemRegularRow( keyValue: "btc", - label: "Standard", + label: S.of(context).standard, iconPath: "assets/new-ui/pjmodal_btc.svg", onTap: () { Clipboard.setData( @@ -42,6 +43,7 @@ class PayjoinCopyModal extends StatelessWidget { ? BitcoinURI(amount: uri.amount, address: uri.address).toString() : uri.address), ); + _announceCopied(context); Navigator.of(context).pop(); }), ListItemRegularRow( @@ -52,6 +54,7 @@ class PayjoinCopyModal extends StatelessWidget { Clipboard.setData( ClipboardData(text: uri.toString()), ); + _announceCopied(context); Navigator.of(context).pop(); }) ] @@ -63,4 +66,15 @@ class PayjoinCopyModal extends StatelessWidget { ), ); } + + // Copying pops this sheet right away, so no widget survives to carry a + // semantics state change: this path still needs a direct announcement. It is + // skipped on platforms that deprecate announcements (android, where they + // clear TalkBack's speech queue and the system shows its own clipboard + // confirmation anyway). + void _announceCopied(BuildContext context) { + if (!MediaQuery.supportsAnnounceOf(context)) return; + SemanticsService.sendAnnouncement( + View.of(context), S.of(context).copied, Directionality.of(context)); + } } diff --git a/lib/new-ui/widgets/receive_page/receive_address_type.dart b/lib/new-ui/widgets/receive_page/receive_address_type.dart index 46a5c6cea..7331885b8 100644 --- a/lib/new-ui/widgets/receive_page/receive_address_type.dart +++ b/lib/new-ui/widgets/receive_page/receive_address_type.dart @@ -42,52 +42,64 @@ class ReceiveAddressTypeDisplay extends StatelessWidget { iconPath = "assets/new-ui/address-type-picker-icons/litecoin.svg"; } - return GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: () => _showPicker(context), - child: Row( - key: ValueKey("$text$largeQrMode"), - mainAxisSize: MainAxisSize.max, - mainAxisAlignment: MainAxisAlignment.center, - spacing: 12.0, - children: [ - if (iconPath != null) - CakeImageWidget( - imageUrl: iconPath, - width: 24, - height: 24, - colorFilter: ColorFilter.mode( - Theme.of(context).colorScheme.primary, - BlendMode.srcIn, - ), - ), - Text( - text, - style: TextStyle( - fontSize: 16, - color: Theme.of(context).colorScheme.primary, - ), - ), - if (!largeQrMode) - Container( - width: 24, - height: 24, - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainer, - borderRadius: BorderRadius.circular(999999), - ), - child: IconButton( - padding: EdgeInsets.zero, - constraints: BoxConstraints(), - onPressed: () => _showPicker(context), - icon: (Icon( + // The row and the chevron button trigger the same picker, so only the + // row is exposed and the chevron is treated as decoration. + return MergeSemantics( + child: Semantics( + button: true, + label: S.of(context).address_type, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => _showPicker(context), + child: Row( + key: ValueKey("$text$largeQrMode"), + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.center, + spacing: 12.0, + children: [ + if (iconPath != null) + ExcludeSemantics( + child: CakeImageWidget( + imageUrl: iconPath, + width: 24, + height: 24, + colorFilter: ColorFilter.mode( + Theme.of(context).colorScheme.primary, + BlendMode.srcIn, + ), + ), + ), + Text( + text, + style: TextStyle( + fontSize: 16, color: Theme.of(context).colorScheme.primary, - size: 20, - Icons.keyboard_arrow_down, - )), + ), ), - ), - ], + if (!largeQrMode) + ExcludeSemantics( + child: Container( + width: 24, + height: 24, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainer, + borderRadius: BorderRadius.circular(999999), + ), + child: IconButton( + padding: EdgeInsets.zero, + constraints: BoxConstraints(), + onPressed: () => _showPicker(context), + icon: (Icon( + color: Theme.of(context).colorScheme.primary, + size: 20, + Icons.keyboard_arrow_down, + )), + ), + ), + ), + ], + ), + ), ), ); }, diff --git a/lib/new-ui/widgets/receive_page/receive_address_type_selector.dart b/lib/new-ui/widgets/receive_page/receive_address_type_selector.dart index 1a3ce73d5..24e50ef3c 100644 --- a/lib/new-ui/widgets/receive_page/receive_address_type_selector.dart +++ b/lib/new-ui/widgets/receive_page/receive_address_type_selector.dart @@ -134,38 +134,49 @@ class _ReceiveAddressTypeSelectorState extends State borderRadius: BorderRadius.vertical( top: Radius.circular(20), bottom: _otherOptionsExpanded ? Radius.zero : Radius.circular(20)), - child: InkWell( - highlightColor: Theme.of(context).colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.vertical( - top: Radius.circular(20), - bottom: !_otherOptionsExpanded ? Radius.zero : Radius.circular(20)), - onTap: () { - setState(() { - _otherOptionsExpanded = !_otherOptionsExpanded; - }); - }, - child: Container( - height: 64.0, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 18.0), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - S.of(context).more_options, - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w400, - color: Theme.of(context).colorScheme.primary), + child: MergeSemantics( + child: Semantics( + button: true, + expanded: _otherOptionsExpanded, + child: InkWell( + highlightColor: + Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.vertical( + top: Radius.circular(20), + bottom: !_otherOptionsExpanded + ? Radius.zero + : Radius.circular(20)), + onTap: () { + setState(() { + _otherOptionsExpanded = !_otherOptionsExpanded; + }); + }, + child: Container( + height: 64.0, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 18.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + S.of(context).more_options, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w400, + color: Theme.of(context).colorScheme.primary), + ), + ExcludeSemantics( + child: AnimatedRotation( + duration: ReceiveAddressTypeSelector + .otherOptionsExpandDuration, + turns: _otherOptionsExpanded ? 0.0 : 0.5, + curve: Curves.easeOut, + child: CakeImageWidget( + imageUrl: "assets/new-ui/dropdown_arrow.svg")), + ) + ], ), - AnimatedRotation( - duration: - ReceiveAddressTypeSelector.otherOptionsExpandDuration, - turns: _otherOptionsExpanded ? 0.0 : 0.5, - curve: Curves.easeOut, - child: CakeImageWidget( - imageUrl: "assets/new-ui/dropdown_arrow.svg")) - ], + ), ), ), ), @@ -180,40 +191,45 @@ class _ReceiveAddressTypeSelectorState extends State duration: ReceiveAddressTypeSelector.otherOptionsExpandDuration, curve: Curves.easeOut, alignment: Alignment.topCenter, - child: Container( - height: _otherOptionsExpanded ? null : 0, - child: ListView.separated( - shrinkWrap: true, - padding: EdgeInsets.zero, - physics: NeverScrollableScrollPhysics(), - itemCount: otherOptions.length, - itemBuilder: (context, index) { - final opt = otherOptions[index]; + // Collapsed to zero height: keep the options out of + // the semantics tree so they cannot be focused. + child: ExcludeSemantics( + excluding: !_otherOptionsExpanded, + child: Container( + height: _otherOptionsExpanded ? null : 0, + child: ListView.separated( + shrinkWrap: true, + padding: EdgeInsets.zero, + physics: NeverScrollableScrollPhysics(), + itemCount: otherOptions.length, + itemBuilder: (context, index) { + final opt = otherOptions[index]; - return ReceiveAddressTypeRow( - option: opt, - roundedTop: false, - roundedBottom: index == otherOptions.length - 1, - selected: - widget.receiveOptionViewModel.selectedReceiveOption == opt, - onItemTap: () { - Navigator.of(context).pop(opt); - }, - receiveOptionViewModel: widget.receiveOptionViewModel, - ); - }, - separatorBuilder: (context, index) { - if ((widget.receiveOptionViewModel.selectedReceiveOption == - otherOptions[index]) || - (index != otherOptions.length - 1 && - widget.receiveOptionViewModel.selectedReceiveOption == - otherOptions[index + 1])) return Container(); + return ReceiveAddressTypeRow( + option: opt, + roundedTop: false, + roundedBottom: index == otherOptions.length - 1, + selected: + widget.receiveOptionViewModel.selectedReceiveOption == opt, + onItemTap: () { + Navigator.of(context).pop(opt); + }, + receiveOptionViewModel: widget.receiveOptionViewModel, + ); + }, + separatorBuilder: (context, index) { + if ((widget.receiveOptionViewModel.selectedReceiveOption == + otherOptions[index]) || + (index != otherOptions.length - 1 && + widget.receiveOptionViewModel.selectedReceiveOption == + otherOptions[index + 1])) return Container(); - return Padding( - padding: const EdgeInsets.only(left: 44.0, right: 36.0), - child: HorizontalSectionDivider(), - ); - }, + return Padding( + padding: const EdgeInsets.only(left: 44.0, right: 36.0), + child: HorizontalSectionDivider(), + ); + }, + ), ), ), ), @@ -264,65 +280,75 @@ class ReceiveAddressTypeRow extends StatelessWidget { top: roundedTop ? Radius.circular(20) : Radius.zero, bottom: roundedBottom ? Radius.circular(20) : Radius.zero, ), - child: InkWell( - onTap: onItemTap, - child: Container( - height: rowHeight, - decoration: BoxDecoration( - color: selected - ? Theme.of(context).colorScheme.surfaceContainerHigh - : Colors.transparent, - borderRadius: BorderRadius.vertical( - top: roundedTop ? Radius.circular(20) : Radius.zero, - bottom: roundedBottom ? Radius.circular(20) : Radius.zero, - )), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 10.0), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - mainAxisSize: MainAxisSize.max, - children: [ - Row( + // The row itself carries the selection state; the checkbox next to it is + // only a visual echo of it and must not be announced separately. + child: MergeSemantics( + child: Semantics( + selected: selected, + inMutuallyExclusiveGroup: true, + child: InkWell( + onTap: onItemTap, + child: Container( + height: rowHeight, + decoration: BoxDecoration( + color: selected + ? Theme.of(context).colorScheme.surfaceContainerHigh + : Colors.transparent, + borderRadius: BorderRadius.vertical( + top: roundedTop ? Radius.circular(20) : Radius.zero, + bottom: roundedBottom ? Radius.circular(20) : Radius.zero, + )), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 10.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + mainAxisSize: MainAxisSize.max, children: [ - if (iconPath != null) - CakeImageWidget( - imageUrl: iconPath!, - width: iconSize, - height: iconSize, - colorFilter: ColorFilter.mode( - Theme.of(context).colorScheme.onSurfaceVariant, BlendMode.srcIn), - ) - else - Container(width: iconSize, height: iconSize), - Padding( - padding: const EdgeInsets.all(10.0), - child: Column( - mainAxisSize: MainAxisSize.max, - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - option.value, - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w500, - color: Theme.of(context).colorScheme.onSurface), + Row( + children: [ + if (iconPath != null) + ExcludeSemantics( + child: CakeImageWidget( + imageUrl: iconPath!, + width: iconSize, + height: iconSize, + colorFilter: ColorFilter.mode( + Theme.of(context).colorScheme.onSurfaceVariant, BlendMode.srcIn), + ), + ) + else + Container(width: iconSize, height: iconSize), + Padding( + padding: const EdgeInsets.all(10.0), + child: Column( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + option.value, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + color: Theme.of(context).colorScheme.onSurface), + ), + if (option.description != null) + Text( + option.description!, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w400, + color: Theme.of(context).colorScheme.onSurfaceVariant), + ) + ], ), - if (option.description != null) - Text( - option.description!, - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w400, - color: Theme.of(context).colorScheme.onSurfaceVariant), - ) - ], - ), - ) + ) + ], + ), + ExcludeSemantics(child: RoundedCheckbox(value: selected)) ], ), - RoundedCheckbox(value: selected) - ], + ), ), ), ), diff --git a/lib/new-ui/widgets/receive_page/receive_amount_display.dart b/lib/new-ui/widgets/receive_page/receive_amount_display.dart index 882bea55f..a69e03aaa 100644 --- a/lib/new-ui/widgets/receive_page/receive_amount_display.dart +++ b/lib/new-ui/widgets/receive_page/receive_amount_display.dart @@ -22,50 +22,54 @@ class ReceiveAmountDisplay extends StatelessWidget { curve: Curves.easeOutCubic, heightFactor: largeQrMode || walletAddressListViewModel.displayAmount.isEmpty ? 0 : 1, alignment: Alignment.topCenter, - child: Container( - width: double.infinity, - child: Row( - spacing: 4, - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(12), - color: Theme.of(context).colorScheme.surfaceContainer), - child: Padding( - padding: const EdgeInsets.all(8.0), - child: Row( - spacing: 8.0, - children: [ - Text( - walletAddressListViewModel.displayAmount, - style: TextStyle( - color: Theme.of(context).colorScheme.primary, - fontSize: 16, - fontWeight: FontWeight.w500), - ), - Text(walletAddressListViewModel.cryptoCurrencySymbol, + // Amount, currency symbol and fiat equivalent describe one value, so + // they are announced as a single node. + child: MergeSemantics( + child: Container( + width: double.infinity, + child: Row( + spacing: 4, + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + color: Theme.of(context).colorScheme.surfaceContainer), + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Row( + spacing: 8.0, + children: [ + Text( + walletAddressListViewModel.displayAmount, style: TextStyle( color: Theme.of(context).colorScheme.primary, fontSize: 16, - fontWeight: FontWeight.w500)) - ], + fontWeight: FontWeight.w500), + ), + Text(walletAddressListViewModel.cryptoCurrencySymbol, + style: TextStyle( + color: Theme.of(context).colorScheme.primary, + fontSize: 16, + fontWeight: FontWeight.w500)) + ], + ), ), ), - ), - if (!walletAddressListViewModel.isFiatDisabled) - Padding( - padding: const EdgeInsets.all(8.0), - child: Text( - _getFiatAmount(), - style: TextStyle( - color: Theme.of(context).colorScheme.onSurfaceVariant, - fontSize: 16, - fontWeight: FontWeight.w500), - ), - ) - ], + if (!walletAddressListViewModel.isFiatDisabled) + Padding( + padding: const EdgeInsets.all(8.0), + child: Text( + _getFiatAmount(), + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant, + fontSize: 16, + fontWeight: FontWeight.w500), + ), + ) + ], + ), ), ), ), diff --git a/lib/new-ui/widgets/receive_page/receive_amount_modal.dart b/lib/new-ui/widgets/receive_page/receive_amount_modal.dart index bbc1daecf..4166750ba 100644 --- a/lib/new-ui/widgets/receive_page/receive_amount_modal.dart +++ b/lib/new-ui/widgets/receive_page/receive_amount_modal.dart @@ -66,54 +66,68 @@ class _ReceiveAmountModalState extends State { spacing: 12, children: [ if (widget.walletAddressListViewModel.hasTokensList) ...[ - Text("Token"), - GestureDetector( - onTap: () { - _presentTokenCurrencyPicker(context); - }, - child: Observer( - builder: (_) => Container( - height: 60, - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHigh, - borderRadius: BorderRadius.circular(16), - ), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: Row( - mainAxisSize: MainAxisSize.max, - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Row( - spacing: 8, - children: [ - TokenImageWidget( - imageUrl: widget.walletAddressListViewModel.tokenCurrency - ?.iconPath ?? - widget.walletAddressListViewModel.currencies.first - .iconPath ?? - "", - size: 32, - ), - Text((widget.walletAddressListViewModel.tokenCurrency ?? - widget.walletAddressListViewModel.currencies.first - as CryptoCurrency) - .title - .toUpperCase()) - ], - ), - RotatedBox( - quarterTurns: 2, - child: CakeImageWidget( - imageUrl: "assets/new-ui/dropdown_arrow.svg")) - ], + // The caption is reused as the picker's semantics label, + // so it must not be announced as a separate node. + ExcludeSemantics(child: Text(S.of(context).token)), + MergeSemantics( + child: Semantics( + button: true, + label: S.of(context).select_token, + child: GestureDetector( + onTap: () { + _presentTokenCurrencyPicker(context); + }, + child: Observer( + builder: (_) => Container( + height: 60, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(16), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Row( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + spacing: 8, + children: [ + ExcludeSemantics( + child: TokenImageWidget( + imageUrl: widget.walletAddressListViewModel + .tokenCurrency?.iconPath ?? + widget.walletAddressListViewModel.currencies.first + .iconPath ?? + "", + size: 32, + ), + ), + Text((widget.walletAddressListViewModel.tokenCurrency ?? + widget.walletAddressListViewModel.currencies.first + as CryptoCurrency) + .title + .toUpperCase()) + ], + ), + ExcludeSemantics( + child: RotatedBox( + quarterTurns: 2, + child: CakeImageWidget( + imageUrl: "assets/new-ui/dropdown_arrow.svg")), + ) + ], + ), ), ), ), - )), + ), + ), + ), ], SizedBox(), - Text(S.of(context).amount), + // The caption is reused as the field's semantics label. + ExcludeSemantics(child: Text(S.of(context).amount)), Row( mainAxisAlignment: MainAxisAlignment.center, children: [ @@ -133,74 +147,91 @@ class _ReceiveAmountModalState extends State { width: 2, ), ), - child: TextField( - textAlign: TextAlign.left, - textAlignVertical: TextAlignVertical.center, - controller: _amountController, - keyboardType: TextInputType.numberWithOptions( - signed: false, - decimal: - widget.walletAddressListViewModel.selectedCurrencyDecimals > 0, - ), - inputFormatters: [ - DecimalInputFormatter( - maxDecimals: - widget.walletAddressListViewModel.selectedCurrencyDecimals, - ), - ], - decoration: InputDecoration( - hint: Text( - widget.walletAddressListViewModel.useSatoshi ? "0" : "0.00000000", + child: MergeSemantics( + child: Semantics( + label: S.of(context).amount, + child: TextField( textAlign: TextAlign.left, - style: TextStyle( - fontSize: 16, - color: Theme.of( - context, - ).colorScheme.onSurface.withValues(alpha: 0.5), + textAlignVertical: TextAlignVertical.center, + controller: _amountController, + keyboardType: TextInputType.numberWithOptions( + signed: false, + decimal: + widget.walletAddressListViewModel.selectedCurrencyDecimals > + 0, ), + inputFormatters: [ + DecimalInputFormatter( + maxDecimals: + widget.walletAddressListViewModel.selectedCurrencyDecimals, + ), + ], + decoration: InputDecoration( + hint: Text( + widget.walletAddressListViewModel.useSatoshi + ? "0" + : "0.00000000", + textAlign: TextAlign.left, + style: TextStyle( + fontSize: 16, + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.5), + ), + ), + border: InputBorder.none, + filled: true, + fillColor: Colors.transparent, + ), + style: + TextStyle(color: Theme.of(context).colorScheme.onSurface), ), - border: InputBorder.none, - filled: true, - fillColor: Colors.transparent, ), - style: TextStyle(color: Theme.of(context).colorScheme.onSurface), ), ), ), Expanded( flex: 25, - child: GestureDetector( - onTap: () { - _presentFiatCurrencyPicker(context); - }, - child: Container( - height: 60, - decoration: BoxDecoration( - borderRadius: BorderRadius.only( - topLeft: Radius.circular(0), - bottomLeft: Radius.circular(0), - topRight: Radius.circular(18), - bottomRight: Radius.circular(18), - ), - color: Theme.of(context).colorScheme.surfaceContainerHigh, - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - spacing: 4.0, - children: [ - Observer( - builder: (_) => Text( - widget.walletAddressListViewModel.selectedCurrencySymbol, - style: TextStyle( - color: Theme.of(context).colorScheme.onSurface, - ), + child: MergeSemantics( + child: Semantics( + button: true, + label: S.of(context).select_fiat_currency_title, + child: GestureDetector( + onTap: () { + _presentFiatCurrencyPicker(context); + }, + child: Container( + height: 60, + decoration: BoxDecoration( + borderRadius: BorderRadius.only( + topLeft: Radius.circular(0), + bottomLeft: Radius.circular(0), + topRight: Radius.circular(18), + bottomRight: Radius.circular(18), ), + color: Theme.of(context).colorScheme.surfaceContainerHigh, ), - Icon( - Icons.keyboard_arrow_down, - color: Theme.of(context).colorScheme.primary, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + spacing: 4.0, + children: [ + Observer( + builder: (_) => Text( + widget.walletAddressListViewModel.selectedCurrencySymbol, + style: TextStyle( + color: Theme.of(context).colorScheme.onSurface, + ), + ), + ), + ExcludeSemantics( + child: Icon( + Icons.keyboard_arrow_down, + color: Theme.of(context).colorScheme.primary, + ), + ), + ], ), - ], + ), ), ), ), diff --git a/lib/new-ui/widgets/receive_page/receive_bottom_buttons.dart b/lib/new-ui/widgets/receive_page/receive_bottom_buttons.dart index 1566ff88d..e3cca096e 100644 --- a/lib/new-ui/widgets/receive_page/receive_bottom_buttons.dart +++ b/lib/new-ui/widgets/receive_page/receive_bottom_buttons.dart @@ -52,32 +52,25 @@ class _ReceiveBottomButtonsState extends State { mainAxisAlignment: MainAxisAlignment.center, spacing: 16, children: [ - GestureDetector( - onTap: widget.copyData == null ? widget.onCopyButtonPressed : null, - behavior: HitTestBehavior.opaque, - child: IgnorePointer( - ignoring: widget.copyData == null, - child: CopyWrapper( - data: widget.copyData, - builder: (context, copied) => AnimatedSwitcher( - duration: const Duration(milliseconds: 200), - child: IgnorePointer( - child: ModernButton.svg( - key: ValueKey(copied), - size: 60, - iconSize: 32, - svgPath: "assets/new-ui/copy.svg", - onPressed: () {}, - label: copied ? S.of(context).copied : S.of(context).copy, - iconColor: copied - ? Theme.of(context).colorScheme.primary - : Theme.of(context).colorScheme.surfaceContainer, - backgroundColor: copied - ? Theme.of(context).colorScheme.surfaceContainerHighest - : Theme.of(context).colorScheme.primary, - ), - ), - ), + // The button itself is the only control: it copies when there + // is data to copy, otherwise it opens the payjoin copy modal. + CopyWrapper( + data: widget.copyData, + controlBuilder: (context, copied, onCopy) => AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + child: ModernButton.svg( + key: ValueKey(copied), + size: 60, + iconSize: 32, + svgPath: "assets/new-ui/copy.svg", + onPressed: onCopy ?? widget.onCopyButtonPressed, + label: copied ? S.of(context).copied : S.of(context).copy, + iconColor: copied + ? Theme.of(context).colorScheme.primary + : Theme.of(context).colorScheme.surfaceContainer, + backgroundColor: copied + ? Theme.of(context).colorScheme.surfaceContainerHighest + : Theme.of(context).colorScheme.primary, ), ), ), diff --git a/lib/new-ui/widgets/receive_page/receive_info_box.dart b/lib/new-ui/widgets/receive_page/receive_info_box.dart index d2598b241..db34cef16 100644 --- a/lib/new-ui/widgets/receive_page/receive_info_box.dart +++ b/lib/new-ui/widgets/receive_page/receive_info_box.dart @@ -71,12 +71,14 @@ class ReceiveInfoBox extends StatelessWidget { spacing: 10, children: [ if (iconPath.isNotEmpty) - CakeImageWidget( - imageUrl: iconPath, - width: 16, - height: 16, - colorFilter: ColorFilter.mode( - Theme.of(context).colorScheme.onSurfaceVariant, BlendMode.srcIn), + ExcludeSemantics( + child: CakeImageWidget( + imageUrl: iconPath, + width: 16, + height: 16, + colorFilter: ColorFilter.mode( + Theme.of(context).colorScheme.onSurfaceVariant, BlendMode.srcIn), + ), ), Flexible( child: Column( @@ -96,15 +98,20 @@ class ReceiveInfoBox extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ if (bottomWidget != null) bottomWidget!, - GestureDetector( - onTap: onDismissed, - child: Text( - S.of(context).dismiss, - style: TextStyle( - color: Theme.of(context).colorScheme.primary, - fontSize: 12, - fontWeight: FontWeight.w300), - )), + MergeSemantics( + child: Semantics( + button: true, + child: GestureDetector( + onTap: onDismissed, + child: Text( + S.of(context).dismiss, + style: TextStyle( + color: Theme.of(context).colorScheme.primary, + fontSize: 12, + fontWeight: FontWeight.w300), + )), + ), + ), ], ) ]), @@ -136,65 +143,69 @@ class InfoboxCurrencyRow extends StatelessWidget { final double stackWidth = iconSize + (overlap * (currenciesLimited.length)); - return Row( - spacing: 8, - children: [ - CakeImageWidget( - imageUrl: chainIconPath, - width: 20, - height: 20, - colorFilter: - ColorFilter.mode(Theme.of(context).colorScheme.onSurfaceVariant, BlendMode.srcIn), - ), - Container( - height: 28, - width: 1, - color: Theme.of(context).colorScheme.surfaceContainerHigh, - ), - SizedBox( - height: iconSize + iconBorder * 2, - width: stackWidth, - child: Stack( - children: [ - Positioned( - top: iconBorder, - left: overlap * currenciesLimited.length, - child: Container( - width: 24, - height: 24, - decoration: BoxDecoration( - color: Colors.white.withAlpha(25), - borderRadius: BorderRadius.circular(9999999)), - child: Icon( - Icons.add, - size: 16, - color: Colors.white.withAlpha(128), + // Purely illustrative: the meaning ("receive any token on ") is + // carried by the infobox message text next to it. + return ExcludeSemantics( + child: Row( + spacing: 8, + children: [ + CakeImageWidget( + imageUrl: chainIconPath, + width: 20, + height: 20, + colorFilter: + ColorFilter.mode(Theme.of(context).colorScheme.onSurfaceVariant, BlendMode.srcIn), + ), + Container( + height: 28, + width: 1, + color: Theme.of(context).colorScheme.surfaceContainerHigh, + ), + SizedBox( + height: iconSize + iconBorder * 2, + width: stackWidth, + child: Stack( + children: [ + Positioned( + top: iconBorder, + left: overlap * currenciesLimited.length, + child: Container( + width: 24, + height: 24, + decoration: BoxDecoration( + color: Colors.white.withAlpha(25), + borderRadius: BorderRadius.circular(9999999)), + child: Icon( + Icons.add, + size: 16, + color: Colors.white.withAlpha(128), + ), ), ), - ), - ...currenciesLimited - .asMap() - .entries - .map((entry) => Positioned( - left: 16.0 * entry.key, - child: Container( - decoration: BoxDecoration( - border: Border.all( - color: Theme.of(context).colorScheme.surfaceContainer, - width: iconBorder), - borderRadius: BorderRadius.circular(9999999)), - child: TokenImageWidget( - imageUrl: entry.value.iconPath ?? '', - size: 24, + ...currenciesLimited + .asMap() + .entries + .map((entry) => Positioned( + left: 16.0 * entry.key, + child: Container( + decoration: BoxDecoration( + border: Border.all( + color: Theme.of(context).colorScheme.surfaceContainer, + width: iconBorder), + borderRadius: BorderRadius.circular(9999999)), + child: TokenImageWidget( + imageUrl: entry.value.iconPath ?? '', + size: 24, + ), ), - ), - )) - .toList() - .reversed, - ], - ), - ) - ], + )) + .toList() + .reversed, + ], + ), + ) + ], + ), ); } } diff --git a/lib/new-ui/widgets/receive_page/receive_label_modal.dart b/lib/new-ui/widgets/receive_page/receive_label_modal.dart index 60e4c01a1..bf2e5e528 100644 --- a/lib/new-ui/widgets/receive_page/receive_label_modal.dart +++ b/lib/new-ui/widgets/receive_page/receive_label_modal.dart @@ -92,27 +92,32 @@ class _ReceiveLabelModalState extends State { return SizedBox(width: 8); }, itemBuilder: (context, index) { - return Container( - height: 36, - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHigh, - borderRadius: BorderRadius.circular(999)), - child: Material( - color: Colors.transparent, - borderRadius: BorderRadius.circular(999), - child: InkWell( - borderRadius: BorderRadius.circular(999), - onTap: () { - _controller.text = defaultLabels[index]; - }, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 10.0), - child: Center( - child: Text( - defaultLabels[index], - style: TextStyle( - fontWeight: FontWeight.w400, - color: Theme.of(context).colorScheme.primary), + return MergeSemantics( + child: Semantics( + button: true, + child: Container( + height: 36, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(999)), + child: Material( + color: Colors.transparent, + borderRadius: BorderRadius.circular(999), + child: InkWell( + borderRadius: BorderRadius.circular(999), + onTap: () { + _controller.text = defaultLabels[index]; + }, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 10.0), + child: Center( + child: Text( + defaultLabels[index], + style: TextStyle( + fontWeight: FontWeight.w400, + color: Theme.of(context).colorScheme.primary), + ), + ), ), ), ), diff --git a/lib/new-ui/widgets/receive_page/receive_label_widget.dart b/lib/new-ui/widgets/receive_page/receive_label_widget.dart index f34eaa2a2..f0657d5fa 100644 --- a/lib/new-ui/widgets/receive_page/receive_label_widget.dart +++ b/lib/new-ui/widgets/receive_page/receive_label_widget.dart @@ -22,14 +22,16 @@ class ReceiveLabelWidget extends StatelessWidget { spacing: 8, mainAxisSize: MainAxisSize.min, children: [ - ClipRect( - child: CakeImageWidget( - imageUrl: "assets/new-ui/label.svg", - width: 24, - height: 24, - colorFilter: ColorFilter.mode( - Theme.of(context).colorScheme.onSurfaceVariant, BlendMode.srcIn), - )), + ExcludeSemantics( + child: ClipRect( + child: CakeImageWidget( + imageUrl: "assets/new-ui/label.svg", + width: 24, + height: 24, + colorFilter: ColorFilter.mode( + Theme.of(context).colorScheme.onSurfaceVariant, BlendMode.srcIn), + )), + ), Text(name, style: TextStyle( fontSize: 12, color: Theme.of(context).colorScheme.onSurfaceVariant)), diff --git a/lib/new-ui/widgets/swap_page/swap_send_external_modal.dart b/lib/new-ui/widgets/swap_page/swap_send_external_modal.dart index 6b33af206..f2185ce39 100644 --- a/lib/new-ui/widgets/swap_page/swap_send_external_modal.dart +++ b/lib/new-ui/widgets/swap_page/swap_send_external_modal.dart @@ -212,6 +212,10 @@ class _SwapSendExternalModalState extends State { child: Icon( copied ? Icons.check : Icons.copy, key: ValueKey(copied), + // Only the icon changes here, so label + // the copied state for CopyWrapper's + // live region to announce. + semanticLabel: copied ? S.of(context).copied : null, size: 18, color: Theme.of(context).colorScheme.primary, ), diff --git a/res/values/strings_en.arb b/res/values/strings_en.arb index 648568f30..dc35bd152 100644 --- a/res/values/strings_en.arb +++ b/res/values/strings_en.arb @@ -557,6 +557,7 @@ "incorrect_seed": "The text entered is not valid.", "incorrect_seed_option": "Incorrect. Please try again", "incorrect_seed_option_back": "Incorrect. Please make sure your seed is saved correctly and try again.", + "info": "Info", "infobox_auto_address": "Your receive address will rotate every time you receive a transaction", "infobox_multichain": "Use this address to receive any token or collectible on", "infobox_rotation": "Your receive address will rotate every time you close and reopen this screen", @@ -623,6 +624,7 @@ "long_press_edit_address": "Long press to edit address", "long_press_hide_balance": "Long press card to hide balance", "long_press_show_balance": "Long press card to show balance", + "long_press_to_copy": "Long press to copy", "low_fee": "Low fee", "low_fee_alert": "You currently are using a low network fee priority. This could cause long waits, different rates, or canceled trades. We recommend setting a higher fee for a better experience.", "made_easy": "made easy", @@ -1134,6 +1136,7 @@ "sort_by": "Sort by", "spend_key_private": "Spend key (private)", "spend_key_public": "Spend key (public)", + "standard": "Standard", "starting_tor_proxy": "Starting Tor proxy", "status": "Status: ", "step": "Step", @@ -1211,6 +1214,7 @@ "to_lightning": "to Lightning", "to_on_chain": "to on-chain", "today": "Today", + "token": "Token", "token_already_exists": "Token already exists", "token_decimal": "Token decimal (eg: 6 for USDT)", "token_name": "Token name (eg: Tether)",