Give ConfirmSwiper an accessible-navigation button fallback (#3464)
What changed, and why it matters
This commit improves accessibility for users who rely on screen readers (VoiceOver on iOS, TalkBack on Android). The app's 'swipe to confirm' control did not work well with screen readers because they intercept swipe gestures. The change adds a plain button fallback when a screen reader is active, so blind or low-vision users can still confirm transactions and approvals. It is an accessibility/usability fix, not a security vulnerability fix.
No security action required. Treat as a normal accessibility improvement. If desired, verify that accessibleNavigation correctly triggers in screen-reader testing and that the button label is localized appropriately.
Security signals we found
No security-relevant code changes
No input validation, authentication, authorization, cryptography, or network changes
Accessibility/usability improvement only
Evidence from the diff
The ConfirmSwiper widget now checks MediaQuery.of(context).accessibleNavigation. When true, it renders a NewPrimaryButton using the same onConfirmed callback instead of the drag-to-confirm pill. Callers pass accessibleNavigationModeButtonText for context-appropriate labels (e.g., ‘Confirm’, ‘Send’, ‘Approve’). For the non-accessible path, the swiper is wrapped in a Semantics widget with container=true and excludeSemantics=true so decorative child elements are not exposed as separate accessibility nodes. No security-sensitive logic is changed.
Changed components
lib/new-ui/widgets/confirm_swiper.dartlib/new-ui/pages/bridge/bridge_confirm_sheet.dartlib/new-ui/widgets/send_page/send_confirm_bottom_widget.dartlib/src/screens/wallet_connect/widgets/wc_connection_request_sheet.dartlib/src/screens/wallet_connect/widgets/wc_signing_request_sheet.dartInspect captured patch +38 / −3
diff --git a/lib/new-ui/pages/bridge/bridge_confirm_sheet.dart b/lib/new-ui/pages/bridge/bridge_confirm_sheet.dart
index 4ccbfb4e..96a8c646 100644
--- a/lib/new-ui/pages/bridge/bridge_confirm_sheet.dart
+++ b/lib/new-ui/pages/bridge/bridge_confirm_sheet.dart
@@ -218,6 +218,7 @@ class _BridgeConfirmSheetState extends State<BridgeConfirmSheet> {
bridgeViewModel.executeBridge();
},
swiperText: S.of(context).swipe_to_bridge,
+ accessibleNavigationModeButtonText: S.of(context).confirm,
);
},
),
diff --git a/lib/new-ui/widgets/confirm_swiper.dart b/lib/new-ui/widgets/confirm_swiper.dart
index c023c546..05ebd8b9 100644
--- a/lib/new-ui/widgets/confirm_swiper.dart
+++ b/lib/new-ui/widgets/confirm_swiper.dart
@@ -1,12 +1,23 @@
import 'dart:math';
+import 'package:cake_wallet/new-ui/widgets/new_primary_button.dart';
import 'package:flutter/material.dart';
class ConfirmSwiper extends StatefulWidget {
final VoidCallback onConfirmed;
final String swiperText;
- const ConfirmSwiper({super.key, required this.onConfirmed, required this.swiperText});
+ /// Label of the plain button rendered instead of the swiper when the platform
+ /// reports accessible navigation (VoiceOver / TalkBack), which intercepts the
+ /// horizontal drag the swiper depends on. Falls back to [swiperText].
+ final String? accessibleNavigationModeButtonText;
+
+ const ConfirmSwiper({
+ super.key,
+ required this.onConfirmed,
+ required this.swiperText,
+ this.accessibleNavigationModeButtonText,
+ });
@override
State<ConfirmSwiper> createState() => _ConfirmSwiperState();
@@ -30,6 +41,15 @@ class _ConfirmSwiperState extends State<ConfirmSwiper> {
@override
Widget build(BuildContext context) {
+ if (MediaQuery.of(context).accessibleNavigation) {
+ return NewPrimaryButton(
+ onPressed: widget.onConfirmed,
+ text: widget.accessibleNavigationModeButtonText ?? widget.swiperText,
+ color: Theme.of(context).colorScheme.primary,
+ textColor: Theme.of(context).colorScheme.onPrimary,
+ );
+ }
+
final radius = (pillSize + pillHorizontalPadding * 2) / 2;
return LayoutBuilder(
@@ -41,7 +61,7 @@ class _ConfirmSwiperState extends State<ConfirmSwiper> {
final maxDrag = areaWidth - pillSize - pillHorizontalPadding;
final triggerAt = maxDrag - _triggerThreshold;
- return GestureDetector(
+ final swiper = GestureDetector(
onHorizontalDragUpdate: (d) {
setState(() {
drag = max(pillHorizontalPadding, min(maxDrag, drag + d.delta.dx));
@@ -98,6 +118,17 @@ class _ConfirmSwiperState extends State<ConfirmSwiper> {
),
),
);
+
+ // A single accessibility node for the whole control: the flowing label and
+ // the arrow knob are decorative and must not become separate stops. No
+ // semantics action is exposed here; a screen reader gets the button
+ // variant above instead.
+ return Semantics(
+ container: true,
+ excludeSemantics: true,
+ label: widget.swiperText,
+ child: swiper,
+ );
},
);
}
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 950932e9..c052fcee 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
@@ -47,7 +47,8 @@ class SendConfirmBottomWidget extends StatelessWidget {
onConfirmed: () {
sendViewModel.commitTransaction(context);
},
- swiperText: "${S.of(context).swipe_to_send}");
+ swiperText: "${S.of(context).swipe_to_send}",
+ accessibleNavigationModeButtonText: S.of(context).send);
case IsExecutingState:
return LoadingBottomWidget(
text: "${S.of(context).generating_transaction}...",
diff --git a/lib/src/screens/wallet_connect/widgets/wc_connection_request_sheet.dart b/lib/src/screens/wallet_connect/widgets/wc_connection_request_sheet.dart
index fb22e7e7..f63857dc 100644
--- a/lib/src/screens/wallet_connect/widgets/wc_connection_request_sheet.dart
+++ b/lib/src/screens/wallet_connect/widgets/wc_connection_request_sheet.dart
@@ -80,6 +80,7 @@ class WCConnectionRequestSheet extends StatelessWidget {
padding: const EdgeInsets.symmetric(horizontal: 24),
child: ConfirmSwiper(
swiperText: S.of(context).wc_swipe_to_approve,
+ accessibleNavigationModeButtonText: S.of(context).wc_action_approve,
onConfirmed: () {
if (Navigator.canPop(context)) {
Navigator.of(context).pop(WCBottomSheetResult.one);
diff --git a/lib/src/screens/wallet_connect/widgets/wc_signing_request_sheet.dart b/lib/src/screens/wallet_connect/widgets/wc_signing_request_sheet.dart
index 3b083ba2..d561f870 100644
--- a/lib/src/screens/wallet_connect/widgets/wc_signing_request_sheet.dart
+++ b/lib/src/screens/wallet_connect/widgets/wc_signing_request_sheet.dart
@@ -82,6 +82,7 @@ class WCSigningRequestSheet extends StatelessWidget {
padding: const EdgeInsets.symmetric(horizontal: 24),
child: ConfirmSwiper(
swiperText: swipeLabel,
+ accessibleNavigationModeButtonText: S.of(context).confirm,
onConfirmed: () {
if (Navigator.canPop(context)) {
Navigator.of(context).pop(WCBottomSheetResult.one);
Why this scored 21/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.