diff --git a/prowler-client/flutter/lib/src/screens/home/home_screen.dart b/prowler-client/flutter/lib/src/screens/home/home_screen.dart index 6fa3a5d..040cc6a 100644 --- a/prowler-client/flutter/lib/src/screens/home/home_screen.dart +++ b/prowler-client/flutter/lib/src/screens/home/home_screen.dart @@ -97,6 +97,13 @@ class HomeScreen extends ConsumerWidget { // surfaces the EXISTING About content (brand mantra + // versionInfoProvider + the =^..^= tagline) — no new // copy is invented here. + // + // 07-13 A-2 (UX-REVIEW §4a A-2): tooltip "About Prowler" + // — IconButton.tooltip forwards through Tooltip → + // Semantics.label automatically (Flutter widget contract); + // a11y_semantics_test asserts + // `find.bySemanticsLabel('About Prowler')`. + tooltip: 'About Prowler', onPressed: () => showAboutProwlerSheet(context), ), ), diff --git a/prowler-client/flutter/lib/src/widgets/connect_dial.dart b/prowler-client/flutter/lib/src/widgets/connect_dial.dart index e3b8691..5085d82 100644 --- a/prowler-client/flutter/lib/src/widgets/connect_dial.dart +++ b/prowler-client/flutter/lib/src/widgets/connect_dial.dart @@ -171,7 +171,21 @@ class _ConnectDialState extends ConsumerState }); return Semantics( - label: _label(state), + // 07-13 A-1 (UX-REVIEW §4a A-1): TalkBack reads the dial as a + // labelled control, with the current state interpolated so the user + // hears: + // "Connect to VPN. Currently Tap to prowl." (idle) + // "Connect to VPN. Currently Connecting…." (connecting) + // "Connect to VPN. Currently Tunnel up." (connected) + // "Connect to VPN. Currently Tap to retry." (error) + // + // Why include "VPN" in the SEMANTIC label even though REQ-14 forbids + // it in user-visible UI: TalkBack labels are functional, not branding. + // The user needs to know the control's purpose; "Connect" alone is + // ambiguous (connect what?). Per UX-REVIEW §4a A-1 exact phrasing. + // REQ-14 / D-06-23 forbidden-vocab scan is scoped to user-visible UI + // text; assistive-tech labels are a separate audience. + label: 'Connect to VPN. Currently ${_label(state)}.', button: true, child: GestureDetector( onTap: () async { diff --git a/prowler-client/flutter/lib/src/widgets/prowl_input.dart b/prowler-client/flutter/lib/src/widgets/prowl_input.dart index 001315e..b1cd33f 100644 --- a/prowler-client/flutter/lib/src/widgets/prowl_input.dart +++ b/prowler-client/flutter/lib/src/widgets/prowl_input.dart @@ -252,10 +252,18 @@ class _ProwlInputState extends State { ), ), ), + // 07-13 A-3 (UX-REVIEW §4a A-3): reset icon touch target + // bumped from 44dp → 48dp to meet Material's minimum + // touch-target guideline. Icon glyph stays at 18px; + // surrounding SizedBox + IconButton padding combine to + // ≥48dp on both axes. Tested via a11y_semantics_test: + // `tester.getSize(find.byTooltip(...))` ≥ Size(48, 48). SizedBox( - width: 44, - height: 44, + width: 48, + height: 48, child: IconButton( + iconSize: 20, + padding: const EdgeInsets.all(14), icon: Icon( LucideIcons.rotateCcw, size: 18, diff --git a/prowler-client/flutter/test/widget/a11y_semantics_test.dart b/prowler-client/flutter/test/widget/a11y_semantics_test.dart new file mode 100644 index 0000000..73381ce --- /dev/null +++ b/prowler-client/flutter/test/widget/a11y_semantics_test.dart @@ -0,0 +1,201 @@ +// 07-13 §4a A-1, A-2, A-3 a11y semantics regression test (UX-REVIEW.md §4a). +// +// Verifies: +// * A-1: ConnectDial Semantics.label interpolates the dial state. +// * A-2: Home info IconButton + Logs copy IconButton expose Semantics.label +// via IconButton.tooltip (Flutter widget contract: tooltip is +// forwarded through Tooltip → Semantics.label automatically). +// * A-3: Per-field reset icon in ProwlInput has ≥48dp touch target. + +import 'package:flutter/material.dart'; +import 'package:flutter/semantics.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:prowler/src/generated/prowler_platform.g.dart' as pigeon; +import 'package:prowler/src/screens/home/home_screen.dart'; +import 'package:prowler/src/screens/logs/logs_screen.dart'; +import 'package:prowler/src/state/connection_state.dart'; +import 'package:prowler/src/state/error_state.dart'; +import 'package:prowler/src/state/log_buffer.dart'; +import 'package:prowler/src/widgets/connect_dial.dart'; +import 'package:prowler/src/widgets/prowl_input.dart'; + +class _FakeIdleStatus extends ConnectionStatus { + @override + Stream build() => Stream.value( + pigeon.Status(state: pigeon.ConnectionState.idle), + ); +} + +class _NoErrEvents extends ErrorEvents { + @override + Stream build() => const Stream.empty(); +} + +/// Stub the LogBuffer with a non-empty list so the copy IconButton is +/// rendered (it is gated behind a `data` AsyncValue with `lines.isNotEmpty`). +class _StubLogBuffer extends LogBuffer { + @override + Stream> build() => Stream>.value( + [ + pigeon.LogLine( + timestampMs: 0, + tag: 'stub', + message: 'stub log line for A-2 test', + kind: pigeon.LogKind.info, + ), + ], + ); +} + +void main() { + testWidgets('A-1: ConnectDial Semantics.label interpolates dial state', + (WidgetTester tester) async { + final SemanticsHandle handle = tester.ensureSemantics(); + await tester.pumpWidget( + ProviderScope( + overrides: [ + connectionStatusProvider.overrideWith(() => _FakeIdleStatus()), + ], + child: const MaterialApp( + home: Scaffold(body: ConnectDial()), + ), + ), + ); + await tester.pumpAndSettle(); + + // Walk the semantics tree to find the dial's label. The Semantics + // widget in connect_dial.dart sets `label: 'Connect to VPN. Currently + // ${_label(state)}.'`; on idle state that resolves to + // 'Connect to VPN. Currently Tap to prowl.'. We match against the + // SemanticsNode's `label` property via a predicate to handle the + // case where Semantics nodes carry mixed label + text content. + // WORKAROUND(flutter/flutter#142000): the deprecated + // `tester.binding.pipelineOwner.semanticsOwner` is the only path that + // surfaces a non-null SemanticsOwner inside widget tests under the + // current Flutter SDK on this project (the recommended + // `rootPipelineOwner.semanticsOwner` returns null in the test binding + // root because the dial widget lives inside a child PipelineOwner + // subtree). Suppression cited per CLAUDE.md (no silent lint-ignores). + final SemanticsNode rootNode = tester + // ignore: deprecated_member_use + .binding.pipelineOwner.semanticsOwner!.rootSemanticsNode!; + final List labels = []; + void collect(SemanticsNode node) { + final SemanticsData data = node.getSemanticsData(); + if (data.label.isNotEmpty) { + labels.add(data.label); + } + node.visitChildren((SemanticsNode child) { + collect(child); + return true; + }); + } + collect(rootNode); + // The dial widget's Semantics.label is the prefix; Flutter's + // semantics framework merges the inner dial-caption Text widget's + // text onto the same node, producing a single label of the form + // `Connect to VPN. Currently Tap to prowl.\nTap to prowl`. Assert the + // interpolated prefix is present somewhere in the tree. + expect( + labels.any((String l) => + l.contains('Connect to VPN. Currently Tap to prowl.')), + isTrue, + reason: 'A-1: ConnectDial Semantics.label must interpolate the dial ' + "state — saw labels: $labels", + ); + handle.dispose(); + }); + + testWidgets('A-2: Home info IconButton exposes tooltip "About Prowler" ' + '(IconButton.tooltip → Tooltip widget → SemanticsConfiguration.tooltip ' + '— rev-2 WARNING W2 locked test)', + (WidgetTester tester) async { + await tester.pumpWidget( + ProviderScope( + overrides: [ + connectionStatusProvider.overrideWith(() => _FakeIdleStatus()), + errorEventsProvider.overrideWith(() => _NoErrEvents()), + ], + child: const MaterialApp( + home: Scaffold(body: HomeScreen()), + ), + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + + // IconButton's `tooltip` parameter wraps the child in a Tooltip widget + // which sets `SemanticsConfiguration.tooltip` (NOT `label`). The + // canonical Flutter finder for this surface is `find.byTooltip(...)`. + // TalkBack reads the tooltip text via the same mechanism — this is the + // exact a11y signal UX-REVIEW §4a A-2 asked for. + expect( + find.byTooltip('About Prowler'), + findsOneWidget, + reason: 'Home TopBar info IconButton must carry the tooltip ' + "'About Prowler' so TalkBack reads it as the button's " + 'a11y label.', + ); + }); + + testWidgets('A-2: Logs copy IconButton exposes tooltip "Copy logs to ' + 'clipboard" (rev-2 WARNING W2 locked test)', + (WidgetTester tester) async { + await tester.pumpWidget( + ProviderScope( + overrides: [ + logBufferProvider.overrideWith(() => _StubLogBuffer()), + ], + child: const MaterialApp(home: Scaffold(body: LogsScreen())), + ), + ); + await tester.pumpAndSettle(); + + expect( + find.byTooltip('Copy logs to clipboard'), + findsOneWidget, + reason: "Logs copy IconButton must carry the tooltip 'Copy logs to " + "clipboard' (already present per logs_screen.dart line 106; " + 'this test guards against future tooltip removal).', + ); + }); + + testWidgets('A-3: ProwlInput reset icon has ≥48dp touch target', + (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: ProwlInput( + label: 'Server address', + value: 'edited.example.com', + onChanged: (_) {}, + onResetPressed: () {}, + canReset: true, + ), + ), + ), + ); + await tester.pump(); + + // The reset IconButton carries the tooltip 'Reset Server address to + // default' (per prowl_input.dart line 270, interpolating the label). + final Finder resetIcon = + find.byTooltip('Reset Server address to default'); + expect(resetIcon, findsOneWidget); + final Size size = tester.getSize(resetIcon); + expect( + size.width, + greaterThanOrEqualTo(48.0), + reason: 'A-3: reset icon touch-target width must be ≥48dp ' + '(Material minimum).', + ); + expect( + size.height, + greaterThanOrEqualTo(48.0), + reason: 'A-3: reset icon touch-target height must be ≥48dp ' + '(Material minimum).', + ); + }); +}