From 553a2b67cd0e04b04e044ab848257aea1ed4cfad Mon Sep 17 00:00:00 2001 From: cschlaefke Date: Tue, 3 Nov 2026 09:47:03 +0100 Subject: [PATCH] feat(core): let AppBanner disable its action instead of hiding it Refs #1 Co-Authored-By: Claude Sonnet 5.5 --- app/lib/core/widgets/app_banner.dart | 8 ++++- app/test/core/widgets/app_banner_test.dart | 35 ++++++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/app/lib/core/widgets/app_banner.dart b/app/lib/core/widgets/app_banner.dart index 9860f85..cfc1c8f 100644 --- a/app/lib/core/widgets/app_banner.dart +++ b/app/lib/core/widgets/app_banner.dart @@ -17,6 +17,7 @@ class AppBanner extends StatelessWidget { super.key, this.actionLabel, this.onAction, + this.actionEnabled = true, }) : assert( (actionLabel == null) == (onAction == null), 'actionLabel and onAction belong together', @@ -28,6 +29,11 @@ class AppBanner extends StatelessWidget { final String? actionLabel; final VoidCallback? onAction; + /// `false` keeps the action visible and in the focus order but disabled + /// (e.g. while a request runs). Screen readers still find it and hear that + /// it is unavailable, instead of the control vanishing under their focus. + final bool actionEnabled; + @override Widget build(BuildContext context) { final theme = Theme.of(context); @@ -81,7 +87,7 @@ class AppBanner extends StatelessWidget { Align( alignment: AlignmentDirectional.centerEnd, child: TextButton( - onPressed: onAction, + onPressed: actionEnabled ? onAction : null, style: TextButton.styleFrom(foregroundColor: foreground), child: Text(actionLabel!), ), diff --git a/app/test/core/widgets/app_banner_test.dart b/app/test/core/widgets/app_banner_test.dart index b3deaab..4a5f02f 100644 --- a/app/test/core/widgets/app_banner_test.dart +++ b/app/test/core/widgets/app_banner_test.dart @@ -1,3 +1,5 @@ +import 'dart:ui' show Tristate; + import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:shuffle/core/widgets/app_banner.dart'; @@ -46,6 +48,39 @@ void main() { handle.dispose(); }); + testWidgets('a disabled action stays visible and announced as disabled', ( + tester, + ) async { + final handle = tester.ensureSemantics(); + var calls = 0; + await pumpWidgetUnderTest( + tester, + AppBanner( + type: AppBannerType.info, + typeLabel: 'Note', + message: 'Confirm first', + actionLabel: 'Resend', + onAction: () => calls++, + actionEnabled: false, + ), + ); + + final action = find.widgetWithText(TextButton, 'Resend'); + expect(action, findsOneWidget); + expect(tester.widget(action).onPressed, isNull); + expect( + tester + .getSemantics(action) + .getSemanticsData() + .flagsCollection + .isEnabled, + Tristate.isFalse, + ); + await tester.tap(action, warnIfMissed: false); + expect(calls, 0); + handle.dispose(); + }); + testWidgets('action calls back and is at least 48 dp', (tester) async { var calls = 0; await pumpWidgetUnderTest(