diff --git a/mobile/lib/features/agents/agents_page.dart b/mobile/lib/features/agents/agents_page.dart index e12ac64829..9d08bd9785 100644 --- a/mobile/lib/features/agents/agents_page.dart +++ b/mobile/lib/features/agents/agents_page.dart @@ -190,7 +190,8 @@ class AgentsPage extends HookConsumerWidget { directory.hasError || registered.hasError || ownedDirectory.hasError || - controller.listingFailed; + // Remote listing failures matter only where remote agents are shown. + (supportsRemoteAgents(community) && controller.listingFailed); final loading = !creating.value && (controller.busy || diff --git a/mobile/lib/shared/widgets/buzz_setting_menu.dart b/mobile/lib/shared/widgets/buzz_setting_menu.dart index 7827bb80db..e62290d332 100644 --- a/mobile/lib/shared/widgets/buzz_setting_menu.dart +++ b/mobile/lib/shared/widgets/buzz_setting_menu.dart @@ -62,87 +62,106 @@ class BuzzSettingMenu extends StatelessWidget { ), menuChildren: [ for (final option in options) - MenuItemButton( - key: option.key, - onPressed: () => onSelected(option.value), - style: MenuItemButton.styleFrom( - padding: const EdgeInsets.symmetric(horizontal: Grid.twelve), - ), - // A trailing check keeps labels flush with the menu's edge. - trailingIcon: option.value == value - ? Icon(Icons.check, size: 20, color: context.colors.primary) - : const SizedBox(width: 20), - // Root menu panels ignore size styles and shrink-wrap their items, - // so the label sets the width: the row's, less the item's padding, - // the check and the gap before it. - child: SizedBox( - width: constraints.maxWidth - Grid.twelve * 3 - 20, - child: Text(option.label), + // The check icon carries no semantics, so mark the selection on + // the item's own node. + MergeSemantics( + child: Semantics( + checked: option.value == value, + inMutuallyExclusiveGroup: true, + child: MenuItemButton( + key: option.key, + onPressed: () => onSelected(option.value), + style: MenuItemButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: Grid.twelve, + ), + ), + // A trailing check keeps labels flush with the menu's edge. + trailingIcon: option.value == value + ? Icon( + Icons.check, + size: 20, + color: context.colors.primary, + ) + : const SizedBox(width: 20), + // Root menu panels ignore size styles and shrink-wrap their items, + // so the label sets the width: the row's, less the item's padding, + // the check and the gap before it. + child: SizedBox( + width: constraints.maxWidth - Grid.twelve * 3 - 20, + child: Text(option.label), + ), + ), ), ), ], - builder: (context, menu, _) => Tooltip( - message: '$label: $valueLabel', - // Draw on an ink surface so the tap ripple shows inside the border. - child: Material( - color: context.colors.surface, - shape: shape, - clipBehavior: Clip.antiAlias, - child: InkWell( - onTap: enabled - ? () { - if (menu.isOpen) return menu.close(); - // Drop the keyboard so it can't cover the menu. - FocusManager.instance.primaryFocus?.unfocus(); - menu.open(); - } - : null, - child: Padding( - key: controlKey, - padding: const EdgeInsets.symmetric( - horizontal: Grid.twelve, - vertical: Grid.xs, - ), - child: Opacity( - opacity: enabled ? 1 : 0.5, - child: Row( - children: [ - Expanded( - child: Text( - label, - style: context.textTheme.labelLarge?.copyWith( - color: context.colors.onSurface, - fontWeight: FontWeight.w600, + builder: (context, menu, _) => Semantics( + button: true, + enabled: enabled, + expanded: menu.isOpen, + child: Tooltip( + message: '$label: $valueLabel', + // Draw on an ink surface so the tap ripple shows inside the border. + child: Material( + color: context.colors.surface, + shape: shape, + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: enabled + ? () { + if (menu.isOpen) return menu.close(); + // Drop the keyboard so it can't cover the menu. + FocusManager.instance.primaryFocus?.unfocus(); + menu.open(); + } + : null, + child: Padding( + key: controlKey, + padding: const EdgeInsets.symmetric( + horizontal: Grid.twelve, + vertical: Grid.xs, + ), + child: Opacity( + opacity: enabled ? 1 : 0.5, + child: Row( + children: [ + Expanded( + child: Text( + label, + style: context.textTheme.labelLarge?.copyWith( + color: context.colors.onSurface, + fontWeight: FontWeight.w600, + ), ), ), - ), - if (valueIcon case final icon?) ...[ + if (valueIcon case final icon?) ...[ + Icon( + icon, + size: 16, + color: context.colors.onSurfaceVariant, + ), + const SizedBox(width: Grid.half), + ], + // Long values truncate instead of squeezing the label. + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 180), + child: Text( + valueLabel, + overflow: TextOverflow.ellipsis, + style: context.textTheme.bodyMedium?.copyWith( + color: context.colors.onSurface, + fontWeight: FontWeight.w500, + ), + ), + ), + const SizedBox(width: Grid.half), Icon( - icon, + BuzzIcons.chevronDown, size: 16, color: context.colors.onSurfaceVariant, ), - const SizedBox(width: Grid.half), ], - // Long values truncate instead of squeezing the label. - ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 180), - child: Text( - valueLabel, - overflow: TextOverflow.ellipsis, - style: context.textTheme.bodyMedium?.copyWith( - color: context.colors.onSurface, - fontWeight: FontWeight.w500, - ), - ), - ), - const SizedBox(width: Grid.half), - Icon( - BuzzIcons.chevronDown, - size: 16, - color: context.colors.onSurfaceVariant, - ), - ], + ), ), ), ), diff --git a/mobile/test/features/agents/agents_harness.dart b/mobile/test/features/agents/agents_harness.dart index 93dd426e8c..19d9e1ae80 100644 --- a/mobile/test/features/agents/agents_harness.dart +++ b/mobile/test/features/agents/agents_harness.dart @@ -119,7 +119,8 @@ class AgentsHarness { /// Fails agent profile fetches until the relay recovers. bool relayDown = false; - static Future create() async { + /// [relayUrl] defaults to a hosted community that supports remote agents. + static Future create({String? relayUrl}) async { FlutterSecureStorage.setMockInitialValues({}); SharedPreferences.setMockInitialValues({}); final registry = FakeRegistry(bound: fakeOwner.public); @@ -137,7 +138,7 @@ class AgentsHarness { controller, Community.create( name: 'Test', - relayUrl: 'wss://test.${HostedEnvironment.communitySuffix}', + relayUrl: relayUrl ?? 'wss://test.${HostedEnvironment.communitySuffix}', pubkey: fakeOwner.public, nsec: fakeOwner.nsec, ), diff --git a/mobile/test/features/agents/agents_unsupported_community_test.dart b/mobile/test/features/agents/agents_unsupported_community_test.dart new file mode 100644 index 0000000000..69f5accaf0 --- /dev/null +++ b/mobile/test/features/agents/agents_unsupported_community_test.dart @@ -0,0 +1,18 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'agents_harness.dart'; + +void main() { + testWidgets( + 'a remote listing failure is hidden where remote agents are off', + (tester) async { + final h = await AgentsHarness.create(relayUrl: 'wss://relay.example'); + h.registry.failListing = true; + await h.open(tester, directory: []); + + expect(h.controller.listingFailed, isTrue); + expect(find.text('Could not load agents'), findsNothing); + expect(find.text('You haven’t added any agents yet.'), findsOneWidget); + }, + ); +} diff --git a/mobile/test/shared/widgets/buzz_setting_menu_test.dart b/mobile/test/shared/widgets/buzz_setting_menu_test.dart new file mode 100644 index 0000000000..ed4313f8ea --- /dev/null +++ b/mobile/test/shared/widgets/buzz_setting_menu_test.dart @@ -0,0 +1,57 @@ +import 'dart:ui' show CheckedState, Tristate; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:buzz/shared/theme/theme.dart'; +import 'package:buzz/shared/widgets/buzz_setting_menu.dart'; + +void main() { + Widget menu({required bool enabled}) => MaterialApp( + theme: AppTheme.light(), + home: Scaffold( + body: BuzzSettingMenu( + controlKey: const Key('menu'), + enabled: enabled, + label: 'Effort', + onSelected: (_) {}, + options: const [ + BuzzSettingMenuOption(key: Key('low'), label: 'Low', value: 'low'), + BuzzSettingMenuOption(key: Key('high'), label: 'High', value: 'high'), + ], + value: 'high', + valueLabel: 'High', + ), + ), + ); + + testWidgets('exposes button, expanded and checked semantics', (tester) async { + final handle = tester.ensureSemantics(); + await tester.pumpWidget(menu(enabled: true)); + SemanticsData trigger() => + tester.getSemantics(find.byKey(const Key('menu'))).getSemanticsData(); + expect(trigger().flagsCollection.isButton, isTrue); + expect(trigger().flagsCollection.isEnabled, Tristate.isTrue); + expect(trigger().flagsCollection.isExpanded, Tristate.isFalse); + + await tester.tap(find.byKey(const Key('menu'))); + await tester.pumpAndSettle(); + expect(trigger().flagsCollection.isExpanded, Tristate.isTrue); + SemanticsData option(String key) => + tester.getSemantics(find.byKey(Key(key))).getSemanticsData(); + expect(option('high').flagsCollection.isChecked, CheckedState.isTrue); + expect(option('low').flagsCollection.isChecked, CheckedState.isFalse); + handle.dispose(); + }); + + testWidgets('a disabled menu reads as a disabled button', (tester) async { + final handle = tester.ensureSemantics(); + await tester.pumpWidget(menu(enabled: false)); + final data = tester + .getSemantics(find.byKey(const Key('menu'))) + .getSemanticsData(); + expect(data.flagsCollection.isButton, isTrue); + expect(data.flagsCollection.isEnabled, Tristate.isFalse); + handle.dispose(); + }); +}