diff --git a/mobile/ios/NativeControlTests/SettingMenuSelectionTests.swift b/mobile/ios/NativeControlTests/SettingMenuSelectionTests.swift new file mode 100644 index 0000000000..05f017f722 --- /dev/null +++ b/mobile/ios/NativeControlTests/SettingMenuSelectionTests.swift @@ -0,0 +1,64 @@ +import Flutter +import UIKit + +private final class SettingMenuMessenger: NSObject, FlutterBinaryMessenger { + var selections: [Int] = [] + func send(onChannel channel: String, message: Data?) { + guard let message else { return } + let call = FlutterStandardMethodCodec.sharedInstance().decodeMethodCall(message) + if call.method == "menuAction", let index = call.arguments as? Int { + selections.append(index) + } + } + func send(onChannel channel: String, message: Data?, binaryReply callback: FlutterBinaryReply?) { + send(onChannel: channel, message: message) + callback?(nil) + } + func setMessageHandlerOnChannel( + _ channel: String, binaryMessageHandler handler: FlutterBinaryMessageHandler? + ) -> FlutterBinaryMessengerConnection { 1 } + func cleanUpConnection(_ connection: FlutterBinaryMessengerConnection) {} +} + +@main +struct SettingMenuSelectionTests { + @MainActor static func main() { + for deferred in [false, true] { + let messenger = SettingMenuMessenger() + let view = NavigationGlassButtonPlatformView( + frame: CGRect(x: 0, y: 0, width: 340, height: 56), + viewIdentifier: 1, + arguments: [ + "fillWidth": true, "plain": true, "label": "", "enabled": true, + "deferMenuActions": deferred, + "menu": [ + ["id": 0, "label": "Low", "enabled": true, "checked": false], + ["id": 1, "label": "High", "enabled": true, "checked": true], + ], + ], + messenger: messenger + ) + let button = view.view().subviews.compactMap { $0 as? NavigationGlassButton }.first! + view.view().layoutIfNeeded() + view.view().frame.size.width = 600 + view.view().layoutIfNeeded() + precondition(button.bounds.width == 600) + precondition(button.point(inside: CGPoint(x: 570, y: 28), with: nil)) + let group = button.menu!.children.first as! UIMenu + let choices = group.children.compactMap { $0 as? UIAction } + precondition(choices[0].state == .off && choices[1].state == .on) + // Invoke the actual menu UIAction through UIKit, without synthesizing a + // Flutter method-channel message or depending on dismissal callback order. + let selection = UIButton(primaryAction: choices[0]) + selection.sendActions(for: .touchUpInside) + precondition(messenger.selections == (deferred ? [] : [0])) + if deferred { + precondition(button.pendingMenuAction != nil) + button.pendingMenuAction?() + button.pendingMenuAction = nil + precondition(messenger.selections == [0]) + } + } + print("Native setting menu selection checks passed") + } +} diff --git a/mobile/ios/Runner/JumpToLatestGlassButton.swift b/mobile/ios/Runner/JumpToLatestGlassButton.swift index 8ffc77f4ad..7be37f80e4 100644 --- a/mobile/ios/Runner/JumpToLatestGlassButton.swift +++ b/mobile/ios/Runner/JumpToLatestGlassButton.swift @@ -444,10 +444,18 @@ final class NavigationGlassButtonPlatformView: NSObject, FlutterPlatformView { : NativeTabler.image($0, size: 24) } let action = UIAction(title: item["label"] as? String ?? "", image: symbol, - attributes: item["enabled"] as? Bool == true ? [] : [.disabled]) { [weak self] _ in - self?.button.pendingMenuAction = { [weak self] in + attributes: item["enabled"] as? Bool == true ? [] : [.disabled], + state: item["checked"] as? Bool == true ? .on : .off) { [weak self] _ in + let select = { [weak self] in self?.channel.invokeMethod("menuAction", arguments: item["id"]) } + if arguments?["deferMenuActions"] as? Bool == false { + // Value changes do not navigate. Deliver them directly: UIKit may + // begin dismissing the menu before invoking its selected UIAction. + select() + } else { + self?.button.pendingMenuAction = { select() } + } } groups[groups.count - 1].append(action) } diff --git a/mobile/lib/features/agents/agent_card.dart b/mobile/lib/features/agents/agent_card.dart index 6fc84fb48f..d627668a52 100644 --- a/mobile/lib/features/agents/agent_card.dart +++ b/mobile/lib/features/agents/agent_card.dart @@ -17,10 +17,14 @@ class AgentCard extends StatelessWidget { this.location, this.footer, this.subtitle, + this.isBestie = false, super.key, }); final String name; + + /// Whether this is the current community's selected Bestie. + final bool isBestie; final String? subtitle; final String? imageUrl; @@ -104,9 +108,11 @@ class AgentCard extends StatelessWidget { child: Semantics( container: true, button: true, - label: subtitle == null - ? 'Open $name' - : 'Open $name, $subtitle', + label: [ + 'Open $name', + if (subtitle != null) subtitle, + if (isBestie) 'Your Bestie', + ].join(', '), onTap: onOpen, child: ExcludeSemantics( child: Column( @@ -166,6 +172,20 @@ class AgentCard extends StatelessWidget { ), ), ), + if (isBestie) + Positioned( + top: Grid.twelve, + right: Grid.twelve, + child: IgnorePointer( + child: ExcludeSemantics( + child: Icon( + Icons.star, + size: 18, + color: context.colors.onSurfaceVariant, + ), + ), + ), + ), ], ), ), diff --git a/mobile/lib/features/agents/agent_identity_sections.dart b/mobile/lib/features/agents/agent_identity_sections.dart new file mode 100644 index 0000000000..2da6c5eb77 --- /dev/null +++ b/mobile/lib/features/agents/agent_identity_sections.dart @@ -0,0 +1,63 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; + +import '../../shared/agents/remote_agents.dart'; +import '../../shared/theme/theme.dart'; +import '../../shared/widgets/app_list_card.dart'; +import 'agent_instructions.dart'; + +/// The agent's instructions and management information in shared Settings cards. +class AgentIdentitySections extends HookWidget { + const AgentIdentitySections({ + required this.pubkey, + required this.controller, + super.key, + }); + + final String pubkey; + final RemoteAgentController controller; + + @override + Widget build(BuildContext context) { + useListenable(controller); + final remote = controller.agents.any( + (agent) => agent['agent_pubkey'] == pubkey, + ); + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + AppListCard( + label: 'Instructions', + horizontalPadding: 0, + children: [ + Padding( + padding: const EdgeInsets.all(Grid.xs), + child: SizedBox( + width: double.infinity, + child: AgentInstructions(pubkey: pubkey), + ), + ), + ], + ), + AppListCard( + label: 'Management', + horizontalPadding: 0, + children: [ + Padding( + padding: const EdgeInsets.all(Grid.xs), + child: SizedBox( + width: double.infinity, + child: Text( + remote + ? 'This agent runs on Builderbot. Your desktop doesn’t need to be online for it to work.' + : 'This agent is managed on your desktop. Keep Buzz open and your desktop online for it to work.', + style: context.textTheme.bodyMedium, + ), + ), + ), + ], + ), + ], + ); + } +} diff --git a/mobile/lib/features/agents/agent_instructions.dart b/mobile/lib/features/agents/agent_instructions.dart new file mode 100644 index 0000000000..76256f75b8 --- /dev/null +++ b/mobile/lib/features/agents/agent_instructions.dart @@ -0,0 +1,143 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:uuid/uuid.dart'; + +import '../../shared/community/community_provider.dart'; +import '../../shared/relay/relay.dart'; +import '../../shared/relay/relay_author_provider.dart'; +import '../../shared/theme/theme.dart'; +import '../../shared/widgets/buzz_loading_indicator.dart'; + +/// Reads only the selected owner's community-scoped agent configuration. +final agentInstructionsProvider = FutureProvider.autoDispose + .family((ref, pubkey) async { + final community = ref.watch(activeCommunityProvider).asData?.value; + final owner = community?.pubkey; + if (owner == null) return null; + final relay = ref.watch(relaySessionProvider.notifier); + Future> query(NostrFilter filter) => + relay.queryRelay([filter]); + final relayAuthor = await ref.watch( + relayAuthorProvider(community!.relayUrl).future, + ); + if (relayAuthor == null) return null; + return loadAgentInstructions(pubkey, owner, relayAuthor, query); + }); + +/// Only published config is authoritative; local desktop overrides cannot be +/// inferred from legacy registration or definition prompts. +Future loadAgentInstructions( + String pubkey, + String owner, + String relayAuthor, + Future> Function(NostrFilter) query, +) async { + final channels = await query( + NostrFilter( + kinds: const [39000], + authors: [relayAuthor], + tags: { + '#P': [owner], + '#t': [pubkey], + }, + ), + ); + final configs = channels.where((event) { + final channel = ChannelData.fromEvent(event); + return event.kind == 39000 && + event.pubkey == relayAuthor && + channel.creator == owner && + channel.channelType == 'system' && + channel.labels.contains('agent-config') && + channel.labels.contains(pubkey); + }).toList(); + if (channels.length >= 100) { + throw const FormatException('Too many config channels'); + } + configs.sort((a, b) { + final left = int.tryParse(a.getTagValue('created_at') ?? ''); + final right = int.tryParse(b.getTagValue('created_at') ?? ''); + final order = left == null + ? (right == null ? 0 : 1) + : (right == null ? -1 : left.compareTo(right)); + return order != 0 + ? order + : (a.getTagValue('d') ?? '').compareTo(b.getTagValue('d') ?? ''); + }); + if (configs.isNotEmpty) { + final channel = ChannelData.fromEvent(configs.first); + if (channel.isArchived) return null; + final id = const Uuid().v5(channel.id, 'buzz.agent.instructions'); + final artifacts = await query( + NostrFilter( + kinds: const [45010], + extensions: const {'artifact': 'current'}, + tags: { + '#d': [id], + '#h': [channel.id], + }, + ), + ); + if (artifacts.length != 1) return null; + final event = artifacts.single; + if (event.kind != 45010 || + event.getTagValue('d') != id || + event.getTagValue('h') != channel.id || + event.getTagValue('type') != 'buzz.agent.instructions' || + event.getTagValue('ar') != '1' || + !['create', 'update', 'restore'].contains(event.getTagValue('op'))) { + return null; + } + final data = jsonDecode(event.content); + if (data is! Map || + data['version'] != 1 || + data['text'] is! String || + (data['text'] as String).contains('\u0000')) { + throw const FormatException('Invalid instructions'); + } + return data['text'] as String; + } + return null; +} + +/// Read-only instructions, with explicit unavailable and retry states. +class AgentInstructions extends ConsumerWidget { + const AgentInstructions({required this.pubkey, super.key}); + final String pubkey; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final instructions = ref.watch(agentInstructionsProvider(pubkey)); + return instructions.when( + loading: () => const Center( + child: BuzzLoadingIndicator(semanticLabel: 'Loading instructions'), + ), + error: (_, _) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Could not load instructions.'), + TextButton( + onPressed: () { + final community = ref.read(activeCommunityProvider).asData?.value; + if (community != null) { + ref.invalidate(relayAuthorProvider(community.relayUrl)); + } + ref.invalidate(agentInstructionsProvider(pubkey)); + }, + child: const Text('Retry'), + ), + ], + ), + data: (text) => SelectableText( + text == null + ? 'Instructions aren’t available on mobile for this agent. View them on desktop.' + : text.trim().isEmpty + ? 'No instructions set.' + : text, + style: context.textTheme.bodyMedium, + ), + ); + } +} diff --git a/mobile/lib/features/agents/agent_model_picker.dart b/mobile/lib/features/agents/agent_model_picker.dart index 316c14fb3e..ed65143b13 100644 --- a/mobile/lib/features/agents/agent_model_picker.dart +++ b/mobile/lib/features/agents/agent_model_picker.dart @@ -3,6 +3,7 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import '../../shared/agents/remote_agents.dart'; import '../../shared/theme/theme.dart'; +import '../../shared/widgets/sheet_action_section.dart'; import '../../shared/widgets/buzz_setting_menu.dart'; /// Uses the server catalog for both menus, including model-specific efforts. @@ -46,31 +47,41 @@ class AgentModelPicker extends StatelessWidget { : null; final enabled = !loading && !controller.busy; return Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - BuzzSettingMenu( - controlKey: const Key('agent-model'), - enabled: enabled, - label: 'Model', - onSelected: (value) => onChanged(value, null), - options: [ - for (final model in models) - BuzzSettingMenuOption(label: model.name, value: model.id), + SheetActionSection( + dividerIndent: Grid.xs, + children: [ + BuzzSettingMenu( + controlKey: const Key('agent-model'), + enabled: enabled, + label: 'Model', + grouped: true, + trailingIcon: Icons.unfold_more, + onSelected: (value) => onChanged(value, null), + options: [ + for (final model in models) + BuzzSettingMenuOption(label: model.name, value: model.id), + ], + value: selected?.id, + valueLabel: loading ? 'Loading…' : selected?.name ?? 'Choose', + ), + + BuzzSettingMenu( + controlKey: const Key('agent-effort'), + enabled: enabled && selected != null, + label: 'Thinking', + grouped: true, + trailingIcon: Icons.unfold_more, + onSelected: (value) => onChanged(selected!.id, value), + options: [ + for (final value in selected?.efforts ?? []) + BuzzSettingMenuOption(label: value, value: value), + ], + value: validEffort, + valueLabel: loading ? 'Loading…' : validEffort ?? 'Choose', + ), ], - value: selected?.id, - valueLabel: loading ? 'Loading…' : selected?.name ?? 'Choose', - ), - const SizedBox(height: Grid.xs), - BuzzSettingMenu( - controlKey: const Key('agent-effort'), - enabled: enabled && selected != null, - label: 'Reasoning effort', - onSelected: (value) => onChanged(selected!.id, value), - options: [ - for (final value in selected?.efforts ?? []) - BuzzSettingMenuOption(label: value, value: value), - ], - value: validEffort, - valueLabel: loading ? 'Loading…' : validEffort ?? 'Choose', ), if (modelId != null && selected == null || effort != null && validEffort == null) diff --git a/mobile/lib/features/agents/agents_page.dart b/mobile/lib/features/agents/agents_page.dart index 9d08bd9785..6e836bdad7 100644 --- a/mobile/lib/features/agents/agents_page.dart +++ b/mobile/lib/features/agents/agents_page.dart @@ -4,8 +4,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import '../../shared/widgets/bee_refresh_indicator.dart'; import '../../shared/agents/remote_agents.dart'; import '../../shared/community/community_provider.dart'; +import '../../shared/me/default_agent.dart'; import '../../shared/hosted/hosted_flow.dart'; import '../../shared/hosted/hosted_flow_provider.dart'; import '../../shared/identity_names/identity_names_provider.dart'; @@ -27,6 +29,7 @@ import '../../shared/widgets/frosted_scaffold.dart'; import '../../shared/theme/buzz_icons.dart'; import 'agent_card_grid.dart'; import 'delete_agent_action.dart'; +import 'agent_identity_sections.dart'; import 'bestie_agent_action.dart'; /// Opens the active community's full-page Agents navigation stack. @@ -94,9 +97,14 @@ class _AgentsNavigation extends HookConsumerWidget { ? 'Builderbot agent' : 'Manage on desktop', primaryActionsSpacing: Grid.md, - contextualActionsSpacing: Grid.xxs, - contextualActions: (_) => BestieAgentAction(pubkey: pubkey), - headerLeading: DeleteAgentAction( + contextualActions: (_) => + BestieAgentAction(pubkey: pubkey, compact: true), + agentIdentity: (_) => AgentIdentitySections( + pubkey: pubkey, + controller: controller, + ), + agentSettings: (_) => DeleteAgentAction( + asSettingsRow: true, controller: controller, pubkey: pubkey, ), @@ -144,6 +152,7 @@ class AgentsPage extends HookConsumerWidget { return null; }, [flow.stage]); final community = ref.watch(activeCommunityProvider).asData?.value; + final bestie = ref.watch(defaultAgentProvider).asData?.value; final directory = ref.watch(agentDirectoryProvider); final ownedDirectory = ref.watch(ownedAgentDirectoryProvider); final candidates = { @@ -183,7 +192,14 @@ class AgentsPage extends HookConsumerWidget { agent['agent_pubkey'] as String: agent['agent_name'] as String, }, ); - keys.sort((a, b) => names.labelFor(a).compareTo(names.labelFor(b))); + // Buzz App orders names without case and uses the identity as a stable tie. + keys.sort((a, b) { + final byName = names + .labelFor(a) + .toLowerCase() + .compareTo(names.labelFor(b).toLowerCase()); + return byName != 0 ? byName : a.compareTo(b); + }); final profiles = ref.watch(userCacheProvider); final creating = useState(false); final failed = @@ -287,7 +303,8 @@ class AgentsPage extends HookConsumerWidget { top: false, child: Material( color: Colors.transparent, - child: RefreshIndicator( + child: BeeRefreshIndicator( + edgeOffset: topHeight, onRefresh: refresh, child: CustomScrollView( physics: const AlwaysScrollableScrollPhysics(), @@ -332,6 +349,7 @@ class AgentsPage extends HookConsumerWidget { return AgentCard( key: ValueKey(key), name: names.labelFor(key), + isBestie: key == bestie, imageUrl: profiles[key]?.avatarUrl ?? ownedDirectory.asData?.value diff --git a/mobile/lib/features/agents/bestie_agent_action.dart b/mobile/lib/features/agents/bestie_agent_action.dart index 91286d7e81..29fbbf7588 100644 --- a/mobile/lib/features/agents/bestie_agent_action.dart +++ b/mobile/lib/features/agents/bestie_agent_action.dart @@ -7,12 +7,18 @@ import '../../shared/me/default_agent_writer.dart'; import '../../shared/me/me_sync.dart'; import '../../shared/theme/theme.dart'; import '../../shared/widgets/buzz_loading_indicator.dart'; +import '../../shared/widgets/profile_action_chip.dart'; /// Saves the selected owned agent using the shared desktop/mobile preference. class BestieAgentAction extends HookConsumerWidget { - const BestieAgentAction({required this.pubkey, super.key}); + const BestieAgentAction({ + required this.pubkey, + this.compact = false, + super.key, + }); final String pubkey; + final bool compact; @override Widget build(BuildContext context, WidgetRef ref) { @@ -39,30 +45,50 @@ class BestieAgentAction extends HookConsumerWidget { } return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: compact + ? CrossAxisAlignment.start + : CrossAxisAlignment.stretch, children: [ - Semantics( - selected: selected, - child: FilledButton.icon( + if (compact) + ProfileActionChip( + label: selected ? 'Your Bestie' : 'Make Bestie', + selected: selected, onPressed: saving.value || selected ? null : choose, - style: FilledButton.styleFrom( - backgroundColor: context.colors.surfaceContainerHighest, - foregroundColor: context.colors.onSurface, - disabledBackgroundColor: context.colors.surfaceContainerHighest, - disabledForegroundColor: context.colors.onSurface, - minimumSize: const Size(double.infinity, Grid.xxl), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(Radii.dialog), - ), - ), - icon: saving.value - ? const BuzzLoadingIndicator(size: 18) + leading: saving.value + ? const BuzzLoadingIndicator(size: 14) : selected - ? const Icon(Icons.star, size: 22) + ? const Icon(Icons.star, size: 14) : null, - label: Text(selected ? 'Your Bestie' : 'Make Bestie'), + ) + else + Semantics( + selected: selected, + child: FilledButton.icon( + onPressed: saving.value || selected ? null : choose, + style: FilledButton.styleFrom( + backgroundColor: context.colors.surfaceContainerHighest, + foregroundColor: context.colors.onSurface, + disabledBackgroundColor: context.colors.surfaceContainerHighest, + disabledForegroundColor: context.colors.onSurface, + minimumSize: Size( + compact ? 0 : double.infinity, + compact ? 48 : Grid.xxl, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular( + compact ? Radii.full : Radii.dialog, + ), + ), + ), + icon: saving.value + ? const BuzzLoadingIndicator(size: 18) + : selected + ? const Icon(Icons.star, size: 22) + : null, + label: Text(selected ? 'Your Bestie' : 'Make Bestie'), + ), ), - ), if (error.value != null) Semantics( liveRegion: true, diff --git a/mobile/lib/features/agents/create_agent_page.dart b/mobile/lib/features/agents/create_agent_page.dart index 1cc2272ca0..78b47c8605 100644 --- a/mobile/lib/features/agents/create_agent_page.dart +++ b/mobile/lib/features/agents/create_agent_page.dart @@ -256,6 +256,18 @@ class CreateAgentPage extends HookConsumerWidget { ], ), const SizedBox(height: Grid.sm), + AgentModelPicker( + controller: controller, + catalog: catalog, + onRetry: retryCatalog, + modelId: model.value, + effort: effort.value, + onChanged: (id, value) { + model.value = id; + effort.value = value; + }, + ), + const SizedBox(height: Grid.sm), SheetActionSection( label: 'Instructions', children: [ @@ -278,17 +290,6 @@ class CreateAgentPage extends HookConsumerWidget { ], ), const SizedBox(height: Grid.sm), - AgentModelPicker( - controller: controller, - catalog: catalog, - onRetry: retryCatalog, - modelId: model.value, - effort: effort.value, - onChanged: (id, value) { - model.value = id; - effort.value = value; - }, - ), if (busy) ...[ const SizedBox(height: Grid.xs), const LinearProgressIndicator(), diff --git a/mobile/lib/features/agents/delete_agent_action.dart b/mobile/lib/features/agents/delete_agent_action.dart index 353cdfd938..0af24c2d17 100644 --- a/mobile/lib/features/agents/delete_agent_action.dart +++ b/mobile/lib/features/agents/delete_agent_action.dart @@ -7,6 +7,8 @@ import '../../shared/community/community_provider.dart'; import '../../shared/mentions/agent_identity_provider.dart'; import '../../shared/relay/relay.dart'; import '../../shared/theme/theme.dart'; +import '../../shared/widgets/app_list.dart'; +import '../../shared/widgets/sheet_action_section.dart'; import '../../shared/widgets/ios_glass_navigation_button.dart'; import '../../shared/widgets/buzz_navigation_metrics.dart'; import '../../shared/theme/buzz_icons.dart'; @@ -16,15 +18,18 @@ class DeleteAgentAction extends HookConsumerWidget { const DeleteAgentAction({ required this.controller, required this.pubkey, + this.asSettingsRow = false, super.key, }); final RemoteAgentController controller; final String pubkey; + final bool asSettingsRow; @override Widget build(BuildContext context, WidgetRef ref) { useListenable(controller); + final deleting = useState(false); final community = ref.watch(activeCommunityProvider).asData?.value; final agent = controller.agents .where((row) => row['agent_pubkey'] == pubkey) @@ -60,14 +65,21 @@ class DeleteAgentAction extends HookConsumerWidget { ), ); if (confirmed != true || !context.mounted) return; - final success = await controller.deleteAgent( - pubkey: pubkey, - community: community, - isCurrent: () => - context.mounted && - ref.read(activeCommunityProvider).asData?.value?.id == community.id, - fetch: ref.read(relaySessionProvider.notifier).fetchHistory, - ); + deleting.value = true; + bool success; + try { + success = await controller.deleteAgent( + pubkey: pubkey, + community: community, + isCurrent: () => + context.mounted && + ref.read(activeCommunityProvider).asData?.value?.id == + community.id, + fetch: ref.read(relaySessionProvider.notifier).fetchHistory, + ); + } finally { + if (context.mounted) deleting.value = false; + } if (!context.mounted) return; ref.invalidate(agentDirectoryProvider); if (success) { @@ -89,6 +101,18 @@ class DeleteAgentAction extends HookConsumerWidget { } } + if (asSettingsRow) { + return SheetActionSection( + children: [ + AppListRow( + onTap: controller.busy ? null : remove, + titleColor: context.colors.error, + icon: BuzzIcons.trash2, + title: deleting.value ? 'Deleting…' : 'Delete agent', + ), + ], + ); + } if (Theme.of(context).platform == TargetPlatform.iOS) { return IosGlassNavigationButton( icon: IosGlassNavigationIcon.trash, diff --git a/mobile/lib/features/profile/profile_setup_page.dart b/mobile/lib/features/profile/profile_setup_page.dart index 3982c88c04..20e080d9bc 100644 --- a/mobile/lib/features/profile/profile_setup_page.dart +++ b/mobile/lib/features/profile/profile_setup_page.dart @@ -19,6 +19,9 @@ import '../../shared/widgets/onboarding_route.dart'; import 'profile_avatar_draft.dart'; import '../../shared/widgets/playing_avatar_image.dart'; import 'profile_setup_avatar_editor.dart'; +import 'profile_setup_empty_avatar.dart'; + +part 'profile_setup_page/form.dart'; /// Profile setup for onboarding and existing-community entry, with a failure exit. class ProfileSetupPage extends HookConsumerWidget { @@ -96,16 +99,12 @@ class ProfileSetupPage extends HookConsumerWidget { context, builder: (sheetContext) { profileRoute = ModalRoute.of(sheetContext); - return OnboardingStepPage( - title: 'Set up your profile', - onBack: null, - content: _ProfileSetupForm( - avatar: avatar, - onName: (value) => name = value, - editAvatar: editAvatar, - save: save, - notNow: flow.finishProfile, - ), + return _ProfileSetupForm( + avatar: avatar, + onName: (value) => name = value, + editAvatar: editAvatar, + save: save, + notNow: flow.finishProfile, ); }, ), @@ -237,100 +236,3 @@ class ProfileSetupPage extends HookConsumerWidget { return const SizedBox.shrink(); } } - -class _ProfileSetupForm extends HookWidget { - const _ProfileSetupForm({ - required this.avatar, - required this.onName, - required this.editAvatar, - required this.save, - required this.notNow, - }); - final ValueNotifier avatar; - final ValueChanged onName; - final Future Function() editAvatar; - final Future Function() save; - final VoidCallback notNow; - @override - Widget build(BuildContext context) { - final draft = useValueListenable(avatar); - final name = useTextEditingController(); - useListenable(name); - final busy = useState(false); - final error = useState(false); - return PopScope( - canPop: false, - child: Padding( - padding: EdgeInsets.fromLTRB( - 20, - 16, - 20, - MediaQuery.viewInsetsOf(context).bottom + 20, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - IconButton( - onPressed: busy.value ? null : editAvatar, - icon: switch (draft) { - ProfileImageAvatarDraft(:final bytes) => CircleAvatar( - radius: 32, - backgroundImage: MemoryImage(bytes), - ), - ProfileAnimatedAvatarDraft(:final poster) => CircleAvatar( - radius: 32, - backgroundImage: MemoryImage(poster), - ), - ProfileUrlAvatarDraft(:final url) => PlayingAvatarImage( - imageUrl: url, - radius: 32, - fallback: const Icon(Icons.add_a_photo_outlined), - ), - null => const Icon(Icons.add_a_photo_outlined), - }, - iconSize: 64, - tooltip: 'Add profile photo', - ), - TextField( - controller: name, - maxLength: 100, - enabled: !busy.value, - onChanged: onName, - decoration: const InputDecoration(labelText: 'Display name'), - ), - if (error.value) - const Text( - 'Could not save your profile. Try again or choose Not now to continue without saving.', - ), - FilledButton( - style: FilledButton.styleFrom( - minimumSize: const Size.fromHeight(52), - shape: const StadiumBorder(), - ), - onPressed: busy.value || name.text.trim().isEmpty - ? null - : () async { - busy.value = true; - error.value = false; - try { - await save(); - } catch (_) { - if (context.mounted) error.value = true; - } finally { - if (context.mounted) busy.value = false; - } - }, - child: Text(busy.value ? 'Saving…' : 'Continue'), - ), - if (error.value) - TextButton( - onPressed: busy.value ? null : notNow, - child: const Text('Not now'), - ), - ], - ), - ), - ); - } -} diff --git a/mobile/lib/features/profile/profile_setup_page/form.dart b/mobile/lib/features/profile/profile_setup_page/form.dart new file mode 100644 index 0000000000..32f75e56ce --- /dev/null +++ b/mobile/lib/features/profile/profile_setup_page/form.dart @@ -0,0 +1,142 @@ +part of '../profile_setup_page.dart'; + +class _ProfileSetupForm extends HookWidget { + const _ProfileSetupForm({ + required this.avatar, + required this.onName, + required this.editAvatar, + required this.save, + required this.notNow, + }); + + final ValueNotifier avatar; + final ValueChanged onName; + final Future Function() editAvatar; + final Future Function({bool skipAvatar}) save; + final VoidCallback notNow; + + @override + Widget build(BuildContext context) { + final draft = useValueListenable(avatar); + final name = useTextEditingController(); + final nameFocus = useFocusNode(); + useListenable(name); + final choosingAvatar = useState(false); + final busy = useState(false); + final error = useState(false); + final validName = + name.text.trim().isNotEmpty && + name.text.trim().characters.length <= 100; + + void back() { + if (busy.value) return; + error.value = false; + choosingAvatar.value = false; + nameFocus.requestFocus(); + } + + Future submit({bool skipAvatar = false}) async { + if (busy.value || !validName) return; + if (!choosingAvatar.value) { + nameFocus.unfocus(); + choosingAvatar.value = true; + return; + } + if (!skipAvatar && draft == null) return; + busy.value = true; + error.value = false; + try { + await save(skipAvatar: skipAvatar); + } catch (_) { + if (context.mounted) error.value = true; + } finally { + if (context.mounted) busy.value = false; + } + } + + final avatarLabel = draft == null + ? 'Add profile photo' + : 'Edit profile photo'; + return OnboardingStepPage( + title: choosingAvatar.value + ? 'Choose your avatar' + : 'Choose a display name', + subtitle: choosingAvatar.value + ? 'Let’s choose an avatar so people know it’s you.' + : 'Your display name is how people see you in Buzz. You can change it any time.', + onBack: choosingAvatar.value && !busy.value ? back : null, + content: choosingAvatar.value + ? Padding( + padding: const EdgeInsets.symmetric(vertical: Grid.lg), + child: Center( + child: SizedBox.square( + dimension: 220, + child: IconButton( + tooltip: avatarLabel, + onPressed: busy.value ? null : editAvatar, + padding: EdgeInsets.zero, + icon: ExcludeSemantics( + child: switch (draft) { + ProfileImageAvatarDraft(:final bytes) => CircleAvatar( + radius: 110, + backgroundImage: MemoryImage(bytes), + ), + ProfileAnimatedAvatarDraft(:final poster) => + CircleAvatar( + radius: 110, + backgroundImage: MemoryImage(poster), + ), + ProfileUrlAvatarDraft(:final url) => PlayingAvatarImage( + imageUrl: url, + radius: 110, + fallback: const ProfileSetupEmptyAvatar(), + ), + null => const ProfileSetupEmptyAvatar(), + }, + ), + ), + ), + ), + ) + : TextField( + controller: name, + focusNode: nameFocus, + autofocus: true, + maxLength: 100, + textCapitalization: TextCapitalization.words, + textInputAction: TextInputAction.next, + onSubmitted: (_) => submit(), + onChanged: onName, + decoration: const InputDecoration(labelText: 'Display name'), + ), + actions: [ + if (error.value) + Semantics( + liveRegion: true, + child: const Text( + 'Could not save your profile. Try again or choose Not now to continue without saving.', + ), + ), + FilledButton( + onPressed: + busy.value || + !validName || + (choosingAvatar.value && draft == null) + ? null + : submit, + child: Text(busy.value ? 'Saving…' : 'Continue'), + ), + if (choosingAvatar.value) + TextButton( + onPressed: busy.value ? null : () => submit(skipAvatar: true), + child: const Text('Skip'), + ), + if (error.value) + TextButton( + onPressed: busy.value ? null : notNow, + child: const Text('Not now'), + ), + ], + ); + } +} diff --git a/mobile/lib/features/profile/user_profile_sheet.dart b/mobile/lib/features/profile/user_profile_sheet.dart index a2a8e0e97e..db79f98375 100644 --- a/mobile/lib/features/profile/user_profile_sheet.dart +++ b/mobile/lib/features/profile/user_profile_sheet.dart @@ -1,6 +1,8 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import '../../shared/widgets/profile_action_chip.dart'; +import '../../shared/widgets/app_list_card.dart'; import 'package:flutter/services.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -81,6 +83,10 @@ class UserProfileSheet extends HookConsumerWidget { /// Optional action opposite the sheet close control. final Widget? headerLeading; + /// Agent-specific content supplied by the owning surface. + final WidgetBuilder? agentIdentity; + final WidgetBuilder? agentSettings; + /// Space before the primary profile actions. final double primaryActionsSpacing; @@ -96,6 +102,8 @@ class UserProfileSheet extends HookConsumerWidget { this.names, this.contextualActions, this.headerLeading, + this.agentIdentity, + this.agentSettings, this.primaryActionsSpacing = Grid.xs + Grid.half, this.contextualActionsSpacing = Grid.xs, }); @@ -154,15 +162,20 @@ class UserProfileSheet extends HookConsumerWidget { final initial = profile?.initial ?? (pubkey.isNotEmpty ? pubkey[0].toUpperCase() : '?'); + final copyGeneration = useRef(0); + Future copyPublicKey() async { if (npub == null) return; await Clipboard.setData(ClipboardData(text: npub)); if (!context.mounted) return; + final generation = ++copyGeneration.value; copied.value = true; showBuzzToast(context, 'Public key copied', kind: ToastKind.success); unawaited( Future.delayed(const Duration(seconds: 2), () { - if (context.mounted) copied.value = false; + if (context.mounted && copyGeneration.value == generation) { + copied.value = false; + } }), ); } @@ -206,42 +219,62 @@ class UserProfileSheet extends HookConsumerWidget { children: [ // Center the presence chip on the avatar's lower edge. Padding( - padding: const EdgeInsets.only(bottom: Grid.xl / 2), + padding: EdgeInsets.only( + bottom: agentPresentation ? 0 : Grid.xl / 2, + ), child: Stack( clipBehavior: Clip.none, children: [ Align( alignment: Alignment.center, - child: FractionallySizedBox( - widthFactor: 0.5, - child: _ProfileAvatar( - avatarUrl: avatarUrl, - initial: initial, - isAgent: profile?.isAgent == true, - ), + child: agentPresentation + ? SizedBox( + width: Grid.xxl * 2, + child: _ProfileAvatar( + avatarUrl: avatarUrl, + initial: initial, + isAgent: true, + ), + ) + : FractionallySizedBox( + widthFactor: 0.5, + child: _ProfileAvatar( + avatarUrl: avatarUrl, + initial: initial, + isAgent: profile?.isAgent == true, + ), + ), + ), + if (!agentPresentation) + Positioned( + left: 0, + right: 0, + bottom: -(Grid.xl / 2), + child: _ProfilePresenceChip(presence: presence), ), - ), - Positioned( - left: 0, - right: 0, - bottom: -(Grid.xl / 2), - child: _ProfilePresenceChip(presence: presence), - ), ], ), ), - const SizedBox(height: Grid.half), + SizedBox(height: agentPresentation ? Grid.xs : Grid.half), // Display name — centered, large Center( child: Text( displayName, - style: context.textTheme.headlineSmall?.copyWith( - fontWeight: FontWeight.w700, - ), + style: agentPresentation + ? context.textTheme.bodyLarge?.copyWith( + fontSize: 20, + height: 1.4, + letterSpacing: -0.2, + ) + : context.textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.w700, + ), + textAlign: TextAlign.center, ), ), - if (agentSubtitle != null) ...[ + + if (!agentPresentation && agentSubtitle != null) ...[ const SizedBox(height: Grid.xxs), Center( child: Wrap( @@ -318,41 +351,140 @@ class UserProfileSheet extends HookConsumerWidget { ), ], - SizedBox(height: primaryActionsSpacing), + // The 48-point target adds 8 points above the 32-point capsule. + SizedBox( + height: agentPresentation + ? Grid.xxs + : primaryActionsSpacing, + ), - Row( - children: [ - if (pk != currentPubkey) ...[ - Expanded( - child: BuzzActionTile( - icon: isOpeningDirectMessage.value - ? null - : BuzzIcons.messageSquare, - label: isOpeningDirectMessage.value - ? 'Opening…' - : 'Message', - isLoading: isOpeningDirectMessage.value, - loadingSemanticLabel: 'Opening direct message', - onTap: openDirectMessage, + if (agentPresentation) ...[ + Center( + child: Wrap( + alignment: WrapAlignment.center, + crossAxisAlignment: WrapCrossAlignment.center, + spacing: Grid.xxs, + runSpacing: Grid.xxs, + children: [ + if (pk != currentPubkey) + ProfileActionChip( + onPressed: isOpeningDirectMessage.value + ? null + : openDirectMessage, + label: isOpeningDirectMessage.value + ? 'Opening…' + : 'Message', + ), + if (contextualActions != null) + Builder(builder: contextualActions!), + ], + ), + ), + const SizedBox(height: Grid.sm), + if (agentIdentity != null) + Builder(builder: agentIdentity!), + AppListCard( + label: 'Public key', + horizontalPadding: 0, + children: [ + Semantics( + button: true, + enabled: npub != null, + label: copied.value + ? 'Copied public key' + : 'Copy public key', + value: npub, + liveRegion: true, + child: Tooltip( + message: 'Copy public key', + excludeFromSemantics: true, + child: InkWell( + onTap: npub == null ? null : copyPublicKey, + child: Padding( + padding: const EdgeInsets.all(Grid.xs), + child: ExcludeSemantics( + child: Row( + children: [ + Expanded( + child: Text( + npub ?? 'Public key unavailable', + style: context.textTheme.bodySmall, + ), + ), + const SizedBox(width: Grid.xs), + AnimatedSwitcher( + duration: + MediaQuery.disableAnimationsOf( + context, + ) + ? Duration.zero + : const Duration( + milliseconds: 180, + ), + switchInCurve: Curves.easeOutCubic, + switchOutCurve: Curves.easeInCubic, + transitionBuilder: + (child, animation) => + ScaleTransition( + scale: animation, + child: child, + ), + child: Icon( + copied.value + ? BuzzIcons.check + : BuzzIcons.copy, + key: ValueKey(copied.value), + size: 18, + ), + ), + ], + ), + ), + ), + ), ), ), - const SizedBox(width: Grid.twelve), ], - Expanded( - child: BuzzActionTile( - icon: copied.value - ? BuzzIcons.check - : BuzzIcons.key, - label: copied.value ? 'Copied' : 'Copy public key', - isEnabled: npub != null, - onTap: copyPublicKey, + ), + if (agentSettings != null) + Builder(builder: agentSettings!), + ] else ...[ + Row( + children: [ + if (pk != currentPubkey) ...[ + Expanded( + child: BuzzActionTile( + icon: isOpeningDirectMessage.value + ? null + : BuzzIcons.messageSquare, + label: isOpeningDirectMessage.value + ? 'Opening…' + : 'Message', + isLoading: isOpeningDirectMessage.value, + loadingSemanticLabel: 'Opening direct message', + onTap: openDirectMessage, + ), + ), + const SizedBox(width: Grid.twelve), + ], + Expanded( + child: BuzzActionTile( + icon: copied.value + ? BuzzIcons.check + : BuzzIcons.key, + label: copied.value + ? 'Copied' + : 'Copy public key', + isEnabled: npub != null, + onTap: copyPublicKey, + ), ), - ), - ], - ), - SizedBox(height: contextualActionsSpacing), - if (contextualActions != null) - Builder(builder: contextualActions!), + ], + ), + SizedBox(height: contextualActionsSpacing), + if (contextualActions != null) + Builder(builder: contextualActions!), + ], ], ), ), diff --git a/mobile/lib/shared/relay/relay_author_provider.dart b/mobile/lib/shared/relay/relay_author_provider.dart new file mode 100644 index 0000000000..f35b09bddd --- /dev/null +++ b/mobile/lib/shared/relay/relay_author_provider.dart @@ -0,0 +1,54 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:http/http.dart' as http; + +final relayInformationClientProvider = Provider((ref) { + final client = http.Client(); + ref.onDispose(client.close); + return client; +}); + +/// NIP-11 `self` identifies the relay signer; `pubkey` is only a contact key. +final relayAuthorProvider = FutureProvider.autoDispose.family(( + ref, + relayUrl, +) async { + final source = Uri.parse(relayUrl); + final uri = source.replace( + scheme: switch (source.scheme) { + 'wss' => 'https', + 'ws' => 'http', + final scheme => scheme, + }, + ); + final client = ref.watch(relayInformationClientProvider); + final response = await client + .send( + http.Request('GET', uri)..headers['Accept'] = 'application/nostr+json', + ) + .timeout(const Duration(seconds: 3)); + final chunks = StreamIterator( + response.stream.timeout(const Duration(seconds: 3)), + ); + final bytes = []; + try { + if (response.statusCode != 200) return null; + await (() async { + while (await chunks.moveNext()) { + if (bytes.length + chunks.current.length > 256 * 1024) { + throw const FormatException('Relay information exceeds size limit'); + } + bytes.addAll(chunks.current); + } + })().timeout(const Duration(seconds: 3)); + } finally { + await chunks.cancel(); + } + final info = jsonDecode(utf8.decode(bytes)); + final author = info is Map ? info['self'] : null; + return author is String && RegExp(r'^[0-9a-f]{64}$').hasMatch(author) + ? author + : null; +}); diff --git a/mobile/lib/shared/widgets/buzz_setting_menu.dart b/mobile/lib/shared/widgets/buzz_setting_menu.dart index e62290d332..9c5c820634 100644 --- a/mobile/lib/shared/widgets/buzz_setting_menu.dart +++ b/mobile/lib/shared/widgets/buzz_setting_menu.dart @@ -1,8 +1,11 @@ import 'package:flutter/material.dart'; +import 'package:flutter/foundation.dart'; import '../theme/buzz_icons.dart'; import '../theme/theme.dart'; import 'anchored_popover_menu.dart'; +import 'app_list.dart'; +import 'ios_message_create_button.dart'; /// One choice in a [BuzzSettingMenu]. class BuzzSettingMenuOption { @@ -28,6 +31,12 @@ class BuzzSettingMenu extends StatelessWidget { final IconData? valueIcon; final String valueLabel; + /// Use the same borderless row as Settings inside a shared card. + final bool grouped; + + /// Affordance for opening the menu. + final IconData trailingIcon; + const BuzzSettingMenu({ super.key, required this.controlKey, @@ -38,10 +47,67 @@ class BuzzSettingMenu extends StatelessWidget { required this.value, this.valueIcon, required this.valueLabel, + this.grouped = false, + this.trailingIcon = BuzzIcons.chevronDown, }); @override Widget build(BuildContext context) { + if (grouped && defaultTargetPlatform == TargetPlatform.iOS) { + return Stack( + children: [ + ExcludeSemantics( + child: Opacity( + opacity: enabled ? 1 : 0.5, + child: AppListRow( + title: label, + value: valueLabel, + trailing: Icon( + trailingIcon, + size: 16, + color: context.colors.onSurfaceVariant, + ), + ), + ), + ), + Positioned.fill( + child: LayoutBuilder( + builder: (context, constraints) => IosMessageCreateButton( + key: controlKey, + label: '', + fillWidth: true, + accessibilityLabel: '$label: $valueLabel', + width: constraints.maxWidth, + height: constraints.maxHeight, + enabled: enabled, + eagerGestures: false, + deferMenuActions: false, + selectedIndex: options.indexWhere( + (option) => option.value == value, + ), + actions: [ + for (final option in options) + ( + label: option.label, + symbol: '', + onPressed: enabled + ? () => onSelected(option.value) + : null, + ), + ], + ), + ), + ), + ], + ); + } + + void toggleMenu(MenuController menu) { + if (menu.isOpen) return menu.close(); + FocusManager.instance.primaryFocus?.unfocus(); + menu.open(); + } + final shape = RoundedRectangleBorder( side: BorderSide(color: context.colors.outlineVariant), borderRadius: BorderRadius.circular(Radii.container), @@ -102,70 +168,80 @@ class BuzzSettingMenu extends StatelessWidget { 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( + child: grouped + ? 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, - ), + child: AppListRow( + key: controlKey, + title: label, + value: valueLabel, + trailing: Icon( + trailingIcon, + size: 16, + color: context.colors.onSurfaceVariant, + ), + onTap: enabled ? () => toggleMenu(menu) : null, + ), + ) + : Material( + color: context.colors.surface, + shape: shape, + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: enabled ? () => toggleMenu(menu) : 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?) ...[ + 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( + trailingIcon, + size: 16, + color: context.colors.onSurfaceVariant, + ), + ], ), ), - 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( - BuzzIcons.chevronDown, - size: 16, - color: context.colors.onSurfaceVariant, - ), - ], + ), ), ), - ), - ), - ), ), ), ), diff --git a/mobile/lib/shared/widgets/ios_message_create_button.dart b/mobile/lib/shared/widgets/ios_message_create_button.dart index dc79fad4c6..15302cd635 100644 --- a/mobile/lib/shared/widgets/ios_message_create_button.dart +++ b/mobile/lib/shared/widgets/ios_message_create_button.dart @@ -24,6 +24,11 @@ class IosMessageCreateButton extends HookWidget { this.width = 64, this.height = 48, this.plain = true, + this.fillWidth = false, + this.enabled = true, + this.selectedIndex, + this.eagerGestures = true, + this.deferMenuActions = true, this.showIconWithLabel = false, }); @@ -35,6 +40,19 @@ class IosMessageCreateButton extends HookWidget { final String accessibilityLabel; final double width, height; final bool plain; + final bool fillWidth; + + /// Whether the native trigger and its actions are interactive. + final bool enabled; + + /// Option marked with the native menu selection checkmark. + final int? selectedIndex; + + /// Capture gestures immediately; disable for rows inside a scroll view. + final bool eagerGestures; + + /// Wait for menu dismissal before actions that navigate to another route. + final bool deferMenuActions; final bool showIconWithLabel; final List<({String label, String symbol, VoidCallback? onPressed})> actions; @@ -71,6 +89,7 @@ class IosMessageCreateButton extends HookWidget { final avatar = useFuture(avatarFuture, preserveState: false).data; final channel = useState(null); final latest = useRef(actions)..value = actions; + final latestEnabled = useRef(enabled)..value = enabled; final menu = [ for (var i = 0; i < actions.length; i++) { @@ -78,7 +97,8 @@ class IosMessageCreateButton extends HookWidget { 'dividerBefore': dividerBefore.contains(i), 'label': actions[i].label, 'symbol': actions[i].symbol, - 'enabled': actions[i].onPressed != null, + 'enabled': enabled && actions[i].onPressed != null, + 'checked': i == selectedIndex, }, ]; final content = { @@ -89,7 +109,10 @@ class IosMessageCreateButton extends HookWidget { 'showIconWithLabel': showIconWithLabel, 'accessibilityLabel': accessibilityLabel, 'menu': menu, + 'deferMenuActions': deferMenuActions, }; + // Keep one handler per channel. Replacing this effect on enabled changes + // would let the old hook cleanup clear the newly installed handler. useEffect(() { final current = channel.value; if (current == null) return null; @@ -97,7 +120,7 @@ class IosMessageCreateButton extends HookWidget { if (call.method != 'menuAction') return; final index = call.arguments as int; if (index >= 0 && index < latest.value.length) { - latest.value[index].onPressed?.call(); + if (latestEnabled.value) latest.value[index].onPressed?.call(); } }); return () => current.setMethodCallHandler(null); @@ -111,7 +134,7 @@ class IosMessageCreateButton extends HookWidget { current.invokeMethod('setAppearance', { 'brightness': brightness.name, 'foregroundColor': foreground, - 'enabled': true, + 'enabled': enabled, }), ); } @@ -124,7 +147,12 @@ class IosMessageCreateButton extends HookWidget { label, labelFontSize, accessibilityLabel, - ...actions.map((a) => a.onPressed != null), + enabled, + selectedIndex, + deferMenuActions, + showIconWithLabel, + ...dividerBefore, + ...actions.expand((a) => [a.label, a.symbol, a.onPressed != null]), brightness, foreground, ], @@ -140,11 +168,13 @@ class IosMessageCreateButton extends HookWidget { child: UiKitView( viewType: 'buzz/navigation_glass', gestureRecognizers: { - Factory(EagerGestureRecognizer.new), + if (eagerGestures) + Factory(EagerGestureRecognizer.new), }, creationParams: { ...content, 'plain': plain, + 'fillWidth': fillWidth, 'buttonCenterX': width / 2, 'controlWidth': width, 'controlSize': height, @@ -152,7 +182,7 @@ class IosMessageCreateButton extends HookWidget { 'hitTargetHeight': height, 'brightness': brightness.name, 'foregroundColor': foreground, - 'enabled': true, + 'enabled': enabled, }, creationParamsCodec: const StandardMessageCodec(), onPlatformViewCreated: (id) => diff --git a/mobile/lib/shared/widgets/profile_action_chip.dart b/mobile/lib/shared/widgets/profile_action_chip.dart new file mode 100644 index 0000000000..bcbee3e416 --- /dev/null +++ b/mobile/lib/shared/widgets/profile_action_chip.dart @@ -0,0 +1,111 @@ +import 'dart:ui'; + +import 'package:flutter/material.dart'; + +import '../theme/theme.dart'; + +/// Compact desktop-style profile capsule with a mobile-sized touch target. +class ProfileActionChip extends StatelessWidget { + const ProfileActionChip({ + required this.label, + this.leading, + this.onPressed, + this.interactive = true, + this.selected = false, + super.key, + }); + + final String label; + final Widget? leading; + final VoidCallback? onPressed; + final bool interactive; + final bool selected; + + @override + Widget build(BuildContext context) { + final content = Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (leading != null) ...[ + ExcludeSemantics(child: leading!), + const SizedBox(width: Grid.half), + ], + Text( + label, + style: context.textTheme.labelMedium?.copyWith( + fontWeight: FontWeight.w500, + height: 20 / 14, + letterSpacing: 0.035, + ), + ), + ], + ); + final dark = Theme.of(context).brightness == Brightness.dark; + final capsule = DecoratedBox( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(Radii.full), + boxShadow: const [ + BoxShadow(color: Color(0x1A000000), blurRadius: 2), + BoxShadow( + color: Color(0x0F000000), + offset: Offset(0, 4), + blurRadius: 8, + spreadRadius: -2, + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(Radii.full), + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 16, sigmaY: 16), + child: Material( + color: dark ? const Color(0xC71C1C1C) : const Color(0xC7FFFFFF), + shape: const StadiumBorder(), + child: DecoratedBox( + decoration: BoxDecoration( + border: Border( + top: BorderSide( + color: Colors.white.withValues(alpha: dark ? 0.18 : 0.45), + ), + bottom: BorderSide( + color: Colors.white.withValues(alpha: dark ? 0.06 : 0.12), + ), + ), + ), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: Grid.xs, + vertical: Grid.twelve / 2, + ), + child: content, + ), + ), + ), + ), + ), + ); + if (!interactive) { + return Semantics( + label: label, + excludeSemantics: true, + child: ConstrainedBox( + constraints: const BoxConstraints(minHeight: 48), + child: Center(widthFactor: 1, child: capsule), + ), + ); + } + return Semantics( + selected: selected, + child: TextButton( + onPressed: onPressed, + style: TextButton.styleFrom( + padding: EdgeInsets.zero, + minimumSize: const Size(48, 48), + shape: const StadiumBorder(), + foregroundColor: context.colors.onSurface, + ), + child: capsule, + ), + ); + } +} diff --git a/mobile/test/features/agents/agent_instructions_test.dart b/mobile/test/features/agents/agent_instructions_test.dart new file mode 100644 index 0000000000..f7fea7075f --- /dev/null +++ b/mobile/test/features/agents/agent_instructions_test.dart @@ -0,0 +1,202 @@ +import 'dart:convert'; +import 'package:buzz/shared/relay/relay_author_provider.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; + +import 'package:buzz/features/agents/agent_instructions.dart'; +import 'package:buzz/shared/relay/relay.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:uuid/uuid.dart'; + +void main() { + const channel = '89b66a68-9631-4d2f-a032-a70220b6a4aa'; + final owner = 'a' * 64; + final agent = 'b' * 64; + final relayAuthor = 'e' * 64; + final artifact = const Uuid().v5(channel, 'buzz.agent.instructions'); + NostrEvent event( + int kind, + List> tags, + Object content, { + String? author, + }) => NostrEvent( + id: 'c' * 64, + pubkey: author ?? (kind == 39000 ? relayAuthor : owner), + createdAt: 1, + kind: kind, + tags: tags, + content: jsonEncode(content), + sig: 'd' * 128, + ); + final config = event(39000, [ + ['d', channel], + ['P', owner], + ['t', 'system'], + ['t', 'agent-config'], + ['t', agent], + ['created_at', '1'], + ], {}); + final legacy = event( + 30177, + [ + ['d', agent], + ], + {'system_prompt': 'Legacy instructions'}, + ); + final current = event( + 45010, + [ + ['d', artifact], + ['h', channel], + ['type', 'buzz.agent.instructions'], + ['ar', '1'], + ['op', 'update'], + ], + {'version': 1, 'text': 'Current instructions'}, + ); + + test('relay authority uses NIP-11 self, never the contact key', () async { + for (final info in [ + {'self': relayAuthor, 'pubkey': owner}, + {'pubkey': owner}, + {'self': 'invalid', 'pubkey': owner}, + ]) { + final container = ProviderContainer( + overrides: [ + relayInformationClientProvider.overrideWithValue( + MockClient((request) async { + expect(request.url.toString(), 'https://relay.example'); + expect(request.headers['Accept'], 'application/nostr+json'); + return http.Response(jsonEncode(info), 200); + }), + ), + ], + ); + final subscription = container.listen( + relayAuthorProvider('wss://relay.example'), + (_, _) {}, + ); + try { + expect( + await container.read( + relayAuthorProvider('wss://relay.example').future, + ), + info['self'] == relayAuthor ? relayAuthor : null, + ); + } finally { + subscription.close(); + container.dispose(); + } + } + }); + + test( + 'instruction sources preserve precedence and do not revive unavailable config', + () async { + for (final row + in < + ({ + List channels, + List artifacts, + String? expected, + }) + >[ + ( + channels: [config], + artifacts: [current], + expected: 'Current instructions', + ), + (channels: [config], artifacts: [], expected: null), + ( + channels: [config], + artifacts: [ + event(45010, current.tags, {'version': 1, 'text': ''}), + ], + expected: '', + ), + ( + channels: [config], + artifacts: [ + event(45010, [ + ['d', artifact], + ['op', 'delete'], + ], {}), + ], + expected: null, + ), + ( + channels: [ + event(39000, [ + ...config.tags, + ['archived', 'true'], + ], {}), + ], + artifacts: [current], + expected: null, + ), + (channels: [], artifacts: [], expected: null), + ( + channels: [event(39000, config.tags, {}, author: owner)], + artifacts: [current], + expected: null, + ), + ( + channels: [ + event(39000, [ + ['d', channel], + ['P', 'f' * 64], + ['t', 'system'], + ['t', 'agent-config'], + ['t', agent], + ], {}), + ], + artifacts: [current], + expected: null, + ), + ]) { + final result = await loadAgentInstructions( + agent, + owner, + relayAuthor, + (filter) async => switch (filter.kinds.single) { + 39000 => () { + expect(filter.authors, [relayAuthor]); + return row.channels; + }(), + 45010 => + filter.extensions['artifact'] == 'current' && + filter.tags['#h']?.single == channel + ? row.artifacts + : [], + 30177 => [legacy], + _ => [], + }, + ); + expect(result, row.expected); + } + await expectLater( + loadAgentInstructions( + agent, + owner, + relayAuthor, + (_) async => throw StateError('offline'), + ), + throwsStateError, + ); + await expectLater( + loadAgentInstructions( + agent, + owner, + relayAuthor, + (filter) async => filter.kinds.single == 39000 + ? [config] + : [ + event(45010, current.tags, {'version': 2, 'text': 'Invalid'}), + ], + ), + throwsFormatException, + ); + }, + ); +} diff --git a/mobile/test/features/agents/agent_ordering_test.dart b/mobile/test/features/agents/agent_ordering_test.dart new file mode 100644 index 0000000000..3fff34201a --- /dev/null +++ b/mobile/test/features/agents/agent_ordering_test.dart @@ -0,0 +1,40 @@ +import 'package:buzz/features/agents/agent_card.dart'; +import 'package:buzz/features/agents/agents_page.dart'; +import 'package:buzz/shared/profile/user_cache_provider.dart'; +import 'package:buzz/shared/profile/user_profile.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import 'agents_harness.dart'; +import '../../helpers/remote_agents_fixture.dart'; + +void main() { + testWidgets('directory sorts names ignoring case and reorders after rename', ( + tester, + ) async { + final h = await AgentsHarness.create(); + final existing = existingDesktopKeys.public; + await h.open(tester, directory: [existing, fakeAgent]); + final container = ProviderScope.containerOf( + tester.element(find.byType(AgentsPage)), + ); + final profiles = container.read(userCacheProvider.notifier); + profiles.put(UserProfile(pubkey: existing, displayName: 'Zulu')); + profiles.put(UserProfile(pubkey: fakeAgent, displayName: 'alpha')); + await tester.pumpAndSettle(); + expect( + tester + .widgetList(find.byType(AgentCard)) + .map((card) => card.name), + ['alpha', 'Zulu'], + ); + profiles.put(UserProfile(pubkey: existing, displayName: 'aardvark')); + await tester.pumpAndSettle(); + expect( + tester + .widgetList(find.byType(AgentCard)) + .map((card) => card.name), + ['aardvark', 'alpha'], + ); + }); +} diff --git a/mobile/test/features/agents/agents_harness.dart b/mobile/test/features/agents/agents_harness.dart index 19d9e1ae80..f0a9a44664 100644 --- a/mobile/test/features/agents/agents_harness.dart +++ b/mobile/test/features/agents/agents_harness.dart @@ -2,6 +2,7 @@ import 'package:buzz/features/agents/community_bot_profiles.dart'; import 'package:buzz/shared/me/me_sync.dart'; import '../../shared/me/me_sync_test.dart' show FakeRelay; import 'dart:async'; +import 'package:buzz/shared/relay/relay_author_provider.dart'; import 'package:nostr/nostr.dart' as nostr; import 'package:buzz/features/agents/agents_page.dart'; @@ -35,6 +36,17 @@ class _Relay extends FakeRelay { @override SessionState build() => const SessionState(status: SessionStatus.connected); + @override + Future> queryRelay( + List filters, { + Duration timeout = const Duration(seconds: 8), + }) async { + final handler = harness.onQuery; + return handler == null + ? super.queryRelay(filters, timeout: timeout) + : handler(filters.single); + } + @override Future> fetchHistory( NostrFilter filter, { @@ -110,6 +122,7 @@ class GatedController extends RelayFakeController { /// belong to the fake-async zone that created them, so each file holds only /// one widget test that touches the store. class AgentsHarness { + Future> Function(NostrFilter)? onQuery; AgentsHarness._(this.registry, this.controller, this.community); final FakeRegistry registry; final GatedController controller; @@ -118,6 +131,7 @@ class AgentsHarness { /// Fails agent profile fetches until the relay recovers. bool relayDown = false; + bool authorityDown = false; /// [relayUrl] defaults to a hosted community that supports remote agents. static Future create({String? relayUrl}) async { @@ -168,6 +182,10 @@ class AgentsHarness { builderlabAccountProvider.overrideWithValue(registry), remoteAgentControllerProvider.overrideWithValue(controller), relaySessionProvider.overrideWith(() => relay), + relayAuthorProvider(community.relayUrl).overrideWith((ref) async { + if (authorityDown) throw StateError('Relay metadata offline'); + return 'e' * 64; + }), meSyncProvider.overrideWithValue(sync), activeCommunityProvider.overrideWith((ref) async => community), agentDirectoryProvider.overrideWith( diff --git a/mobile/test/features/agents/agents_sheet_test.dart b/mobile/test/features/agents/agents_sheet_test.dart index d748513cef..5c63a0238e 100644 --- a/mobile/test/features/agents/agents_sheet_test.dart +++ b/mobile/test/features/agents/agents_sheet_test.dart @@ -1,9 +1,12 @@ +import 'package:flutter/services.dart'; +import 'package:buzz/shared/widgets/app_list_card.dart'; import 'package:buzz/shared/agents/remote_agents.dart'; import 'package:buzz/shared/me/me_sync.dart'; import 'package:buzz/shared/me/default_agent.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:buzz/features/channels/default_agent_launcher.dart'; import 'package:buzz/features/agents/agents_page.dart'; +import 'package:buzz/features/agents/agent_card.dart'; import 'package:buzz/features/profile/user_profile_sheet.dart'; import 'package:flutter/material.dart'; import 'package:buzz/features/home/home_page.dart'; @@ -18,6 +21,21 @@ void main() { testWidgets('agent profile sheet closes back to the full-page directory', ( tester, ) async { + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + const MethodChannel('dev.fluttercommunity.plus/connectivity_status'), + (_) async => null, + ); + addTearDown( + () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + const MethodChannel('dev.fluttercommunity.plus/connectivity_status'), + null, + ), + ); + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue); final h = await AgentsHarness.create(); await h.open( tester, @@ -57,7 +75,96 @@ void main() { expect(find.byType(UserProfileSheet), findsOneWidget); expect(find.text('Model not shared'), findsNothing); expect(list.mounted, isTrue); + expect(find.text('Identity'), findsNothing); + expect(find.text('Settings'), findsNothing); + expect( + find.descendant( + of: find.byType(UserProfileSheet), + matching: find.text('Manage on desktop'), + ), + findsNothing, + ); + expect( + find.descendant( + of: find.byType(UserProfileSheet), + matching: find.text('Unknown'), + ), + findsNothing, + ); + expect(find.text('Instructions'), findsOneWidget); + expect(find.text('Management'), findsOneWidget); + expect( + find.textContaining('Keep Buzz open and your desktop online'), + findsOneWidget, + ); + expect( + find.textContaining('Instructions aren’t available'), + findsOneWidget, + ); + await tester.pumpAndSettle(); + expect(find.byTooltip('Copy public key'), findsOneWidget); + final semantics = tester.ensureSemantics(); + final platformCalls = []; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + (call) async { + platformCalls.add(call); + return null; + }, + ); + addTearDown( + () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + null, + ), + ); + final keyRow = find.byTooltip('Copy public key'); + await tester.ensureVisible(keyRow); + await tester.tapAt(tester.getTopLeft(keyRow) + const Offset(8, 8)); + await tester.pumpAndSettle(); + expect( + platformCalls.where((call) => call.method == 'Clipboard.setData'), + hasLength(1), + ); + expect( + platformCalls.where((call) => call.method == 'HapticFeedback.vibrate'), + hasLength(1), + ); + expect(find.bySemanticsLabel('Copied public key'), findsOneWidget); + semantics.dispose(); + + await tester.pumpAndSettle(); expect(find.byTooltip('Delete agent'), findsNothing); + // Reopen through the real reader, recover a failed network query, then + // verify the registered instruction text appears rather than profile bio. + await tester.tap(find.byTooltip('Close sheet')); + await tester.pumpAndSettle(); + h.onQuery = (_) async => throw StateError('Offline'); + await tester.tap(find.byKey(ValueKey(existing))); + await tester.pumpAndSettle(); + expect(find.text('Could not load instructions.'), findsOneWidget); + h.onQuery = (_) async => []; + h.authorityDown = true; + await tester.ensureVisible(find.text('Retry')); + await tester.tap(find.text('Retry')); + await tester.pumpAndSettle(); + expect(find.text('Could not load instructions.'), findsOneWidget); + h.authorityDown = false; + await tester.ensureVisible(find.text('Retry')); + await tester.tap(find.text('Retry')); + await tester.pumpAndSettle(); + expect( + find.textContaining('Instructions aren’t available'), + findsOneWidget, + ); + tester.platformDispatcher.textScaleFactorTestValue = 2; + await tester.pumpAndSettle(); + await tester.pumpAndSettle(); + await tester.ensureVisible(find.byTooltip('Copy public key')); + expect(tester.takeException(), isNull); + tester.platformDispatcher.clearTextScaleFactorTestValue(); + await tester.pumpAndSettle(); + await tester.pumpAndSettle(); h.relay.loseAcknowledgment = true; await tester.ensureVisible(find.text('Make Bestie')); await tester.tap(find.text('Make Bestie')); @@ -83,6 +190,17 @@ void main() { expect(find.byType(UserProfileSheet), findsNothing); expect(find.byKey(ValueKey(existing)), findsOneWidget); expect(tester.element(find.byType(AgentsPage)), same(list)); + expect( + tester.widget(find.byKey(ValueKey(existing))).isBestie, + isTrue, + ); + expect( + find.descendant( + of: find.byKey(ValueKey(existing)), + matching: find.byIcon(Icons.star), + ), + findsOneWidget, + ); if (supportsRemoteAgents(h.community)) { h.registry.listed = [ { @@ -95,15 +213,30 @@ void main() { await tester.pumpAndSettle(); await tester.tap(find.byKey(ValueKey(fakeAgent))); await tester.pumpAndSettle(); - expect(find.byTooltip('Delete agent'), findsOneWidget); expect( - tester.getCenter(find.byTooltip('Delete agent')).dx, - lessThan(tester.getCenter(find.byTooltip('Close sheet')).dx), + find.textContaining('This agent runs on Builderbot.'), + findsOneWidget, ); - await tester.tap(find.byTooltip('Delete agent')); + await tester.pumpAndSettle(); + await tester.ensureVisible(find.text('Delete agent')); + expect( + tester.getTopLeft(find.text('Delete agent')).dy, + greaterThan(tester.getTopLeft(find.text('Public key')).dy), + ); + final deleteCard = find.ancestor( + of: find.text('Delete agent'), + matching: find.byType(AppListCard), + ); + expect(deleteCard, findsOneWidget); + expect( + find.descendant(of: deleteCard, matching: find.text('Public key')), + findsNothing, + ); + await tester.tap(find.text('Delete agent')); await tester.pumpAndSettle(); await tester.tap(find.text('Cancel')); await tester.pumpAndSettle(); + await tester.pumpAndSettle(); h.relay.events = []; await tester.ensureVisible(find.text('Make Bestie')); await tester.tap(find.text('Make Bestie')); @@ -112,6 +245,14 @@ void main() { expect(decodeDefaultAgent(h.relay.events.single, savedSync), fakeAgent); await tester.tap(find.byTooltip('Close sheet')); await tester.pumpAndSettle(); + expect( + tester.widget(find.byKey(ValueKey(existing))).isBestie, + isFalse, + ); + expect( + tester.widget(find.byKey(ValueKey(fakeAgent))).isBestie, + isTrue, + ); } ScaffoldMessenger.of(list).clearSnackBars(); await tester.tap(find.byTooltip('Close agents')); diff --git a/mobile/test/features/agents/create_agent_page_test.dart b/mobile/test/features/agents/create_agent_page_test.dart index 1bcb35b455..bd1f14eb46 100644 --- a/mobile/test/features/agents/create_agent_page_test.dart +++ b/mobile/test/features/agents/create_agent_page_test.dart @@ -36,12 +36,23 @@ void main() { final createContext = tester.element(find.byType(CreateAgentPage)); expect(Navigator.of(list), same(Navigator.of(createContext))); + expect(find.text('Thinking'), findsOneWidget); + expect(find.byIcon(Icons.unfold_more), findsNWidgets(2)); + expect( + tester.getTopLeft(find.text('Name')).dy, + lessThan(tester.getTopLeft(find.text('Model')).dy), + ); + expect( + tester.getTopLeft(find.text('Model')).dy, + lessThan(tester.getTopLeft(find.text('Instructions')).dy), + ); + await tester.ensureVisible(find.text('Model')); await tester.tap(find.text('Model')); await tester.pumpAndSettle(); await tester.tap(find.text('Test model').last); await tester.pumpAndSettle(); - await tester.tap(find.text('Reasoning effort')); + await tester.tap(find.text('Thinking')); await tester.pumpAndSettle(); await tester.tap(find.text('high').last); await tester.pumpAndSettle(); @@ -60,7 +71,7 @@ void main() { await tester.pumpAndSettle(); await tester.tap(find.text('Test model').last); await tester.pumpAndSettle(); - await tester.tap(find.text('Reasoning effort')); + await tester.tap(find.text('Thinking')); await tester.pumpAndSettle(); await tester.tap(find.text('high').last); await tester.pumpAndSettle(); diff --git a/mobile/test/features/agents/delete_agent_action_test.dart b/mobile/test/features/agents/delete_agent_action_test.dart index ec63686ce2..6faff5cd8e 100644 --- a/mobile/test/features/agents/delete_agent_action_test.dart +++ b/mobile/test/features/agents/delete_agent_action_test.dart @@ -2,6 +2,8 @@ import 'package:buzz/features/agents/delete_agent_action.dart'; import 'package:buzz/shared/community/community_provider.dart'; import 'package:buzz/shared/theme/theme.dart'; import 'package:flutter/material.dart'; +import 'dart:async'; +import 'package:buzz/shared/widgets/app_list.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:nostr/nostr.dart' as nostr; @@ -30,7 +32,11 @@ void main() { home: Scaffold( body: Column( children: [ - DeleteAgentAction(controller: controller, pubkey: agent), + DeleteAgentAction( + controller: controller, + pubkey: agent, + asSettingsRow: true, + ), DeleteAgentAction(controller: controller, pubkey: 'local'), ], ), @@ -39,8 +45,17 @@ void main() { ), ); await tester.pumpAndSettle(); - expect(find.byTooltip('Delete agent'), findsOneWidget); - await tester.tap(find.byTooltip('Delete agent')); + expect(find.text('Delete agent'), findsOneWidget); + final refresh = Completer(); + final refreshing = controller.run(() => refresh.future); + await tester.pump(); + expect(find.text('Deleting…'), findsNothing); + expect(find.text('Delete agent'), findsOneWidget); + expect(tester.widget(find.byType(AppListRow)).onTap, isNull); + refresh.complete(); + await refreshing; + await tester.pumpAndSettle(); + await tester.tap(find.text('Delete agent')); await tester.pumpAndSettle(); expect(find.text('Delete Scout?'), findsOneWidget); await tester.tap(find.text('Cancel')); diff --git a/mobile/test/features/onboarding/hosted_journeys_test.dart b/mobile/test/features/onboarding/hosted_journeys_test.dart index 22dcada474..7e279e0aaf 100644 --- a/mobile/test/features/onboarding/hosted_journeys_test.dart +++ b/mobile/test/features/onboarding/hosted_journeys_test.dart @@ -281,6 +281,14 @@ void main() { expect(f.probes, isEmpty); await tester.tap(find.byType(CommunityGridOption).first); await f.settle(tester); + await tester.enterText( + find.byType(TextField).hitTestable(), + 'Draft Person', + ); + expect(find.byTooltip('Add profile photo'), findsNothing); + await f.tap(tester, 'Continue'); + expect(find.text('Choose your avatar'), findsOneWidget); + expect(f.profileWrites, isEmpty); await tester.tap(find.byTooltip('Add profile photo')); await f.settle(tester); expect(find.byType(ProfileSetupAvatarEditor), findsOneWidget); @@ -289,45 +297,68 @@ void main() { find.byType(ProfileSetupAvatarEditor, skipOffstage: false), findsNothing, ); - expect(find.text('Set up your profile', skipOffstage: false), findsNothing); + expect(find.text('Choose your avatar', skipOffstage: false), findsNothing); expect(f.profileWrites, isEmpty); }); - journey('fallback profile previews the accepted draft before saving', ( - tester, - f, - ) async { - await f.initialize(); - f.rows.add(f.row('one')); - await f.mount(tester); - // Account restoration may resume independently of the Add choices. - await f.flow.signInAccount(); - await f.settle(tester); - expect(f.flow.stage, HostedFlowStage.chooseCommunities); - expect(f.probes, isEmpty); - await tester.tap(find.byType(CommunityGridOption).first); - await f.settle(tester); - await tester.tap(find.byTooltip('Add profile photo')); - await f.settle(tester); - final url = emojiAvatarDataUrl('🐝', 0xFFFFE75C); - Navigator.of( - tester.element(find.byType(ProfileSetupAvatarEditor)), - ).pop(ProfileUrlAvatarDraft(url)); - await f.settle(tester); - expect( - tester - .widget(find.byType(PlayingAvatarImage)) - .imageUrl, - url, + for (final skipAvatar in [false, true]) { + journey( + 'fallback profile preserves drafts and recovers save failures (skip: $skipAvatar)', + (tester, f) async { + await f.initialize(); + f.rows.add(f.row('one')); + await f.mount(tester); + // Account restoration may resume independently of the Add choices. + await f.flow.signInAccount(); + await f.settle(tester); + expect(f.flow.stage, HostedFlowStage.chooseCommunities); + expect(f.probes, isEmpty); + await tester.tap(find.byType(CommunityGridOption).first); + await f.settle(tester); + await tester.enterText( + find.byType(TextField).hitTestable(), + 'Draft Person', + ); + expect(find.byTooltip('Add profile photo'), findsNothing); + await f.tap(tester, 'Continue'); + expect(find.text('Choose your avatar'), findsOneWidget); + expect(f.profileWrites, isEmpty); + await tester.tap(find.byTooltip('Add profile photo')); + await f.settle(tester); + final url = emojiAvatarDataUrl('🐝', 0xFFFFE75C); + Navigator.of( + tester.element(find.byType(ProfileSetupAvatarEditor)), + ).pop(ProfileUrlAvatarDraft(url)); + await f.settle(tester); + expect( + tester + .widget(find.byType(PlayingAvatarImage)) + .imageUrl, + url, + ); + await tester.tap(find.byTooltip('Back')); + await f.settle(tester); + expect(find.text('Choose a display name'), findsOneWidget); + expect( + tester + .widget(find.byType(TextField).hitTestable()) + .controller! + .text, + 'Draft Person', + ); + await f.tap(tester, 'Continue'); + expect(find.byTooltip('Edit profile photo'), findsOneWidget); + f.profileSaveFailures = 1; + await f.tap(tester, 'Continue'); + expect(f.profileWrites, isEmpty); + expect(find.text('Not now'), findsOneWidget); + expect(find.byTooltip('Edit profile photo'), findsOneWidget); + await f.tap(tester, skipAvatar ? 'Skip' : 'Continue'); + expect(f.profileWrites, ['Draft Person']); + expect(f.profileAvatars, skipAvatar ? isEmpty : [url]); + }, ); - await tester.enterText( - find.byType(TextField).hitTestable(), - 'Draft Person', - ); - await f.tap(tester, 'Continue'); - expect(f.profileWrites, ['Draft Person']); - expect(f.profileAvatars, [url]); - }); + } journey( 'QR credentials enter without an account when domain is not required', @@ -715,6 +746,8 @@ void main() { 'Journey Person', ); await f.tap(tester, 'Continue'); + expect(f.profileWrites, isEmpty); + await f.tap(tester, 'Skip'); expect(f.profileWrites, ['Journey Person']); final saved = (await f.storage.loadAll()).single; expect(saved.id, pending.id); @@ -1126,6 +1159,7 @@ void main() { ); await tester.enterText(find.byType(TextField).hitTestable(), 'Creator'); await f.tap(tester, 'Continue'); + await f.tap(tester, 'Skip'); final saved = (await f.storage.loadAll()).single; expect(saved.relayUrl, 'wss://new-home.communities.buzz.xyz'); expect(saved.nsec, f.keys.nsec); diff --git a/mobile/test/features/profile/profile_setup_fallback_test.dart b/mobile/test/features/profile/profile_setup_fallback_test.dart index 96909f7658..6d22571bcf 100644 --- a/mobile/test/features/profile/profile_setup_fallback_test.dart +++ b/mobile/test/features/profile/profile_setup_fallback_test.dart @@ -44,6 +44,23 @@ void main() { await tester.pump(); await tester.tap(find.text('Continue')); await tester.pump(); + expect(find.text('Choose your avatar'), findsOneWidget); + expect(attempts, 0); + expect( + tester.widget(find.byType(FilledButton)).onPressed, + isNull, + ); + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + expect( + tester.widget(find.byType(TextField)).controller!.text, + 'Kenny', + ); + await tester.tap(find.text('Continue')); + await tester.pumpAndSettle(); + await tester.ensureVisible(find.text('Skip')); + await tester.tap(find.text('Skip')); + await tester.pump(); expect(find.text('Saving…'), findsOneWidget); await tester.binding.handlePopRoute(); await tester.pump(); @@ -53,7 +70,9 @@ void main() { await tester.pumpAndSettle(); expect(attempts, 1); expect(find.text('Not now'), findsOneWidget); - await tester.tap(find.text('Not now')); + await tester.ensureVisible(find.text('Not now')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Not now').hitTestable()); await tester.pumpAndSettle(); await pending; expect(attempts, 1); diff --git a/mobile/test/helpers/hosted_journey_fixture.dart b/mobile/test/helpers/hosted_journey_fixture.dart index f371ebba0c..25a7d8c30c 100644 --- a/mobile/test/helpers/hosted_journey_fixture.dart +++ b/mobile/test/helpers/hosted_journey_fixture.dart @@ -71,6 +71,7 @@ class HostedJourneyFixture { final socketHeaders = >[]; bool offerProfile = true; bool profileUnavailable = false; + int profileSaveFailures = 0; bool rejectList = false; int saveFailures = 0; final knownSaved = {}; @@ -518,11 +519,19 @@ class _Profile extends ProfileNotifier { @override Future updateDisplayName(String name) async { + if (fixture.profileSaveFailures > 0) { + fixture.profileSaveFailures--; + throw StateError('profile save unavailable'); + } fixture.profileWrites.add(name); } @override Future updateSetup(String name, String? avatarUrl) async { + if (fixture.profileSaveFailures > 0) { + fixture.profileSaveFailures--; + throw StateError('profile save unavailable'); + } fixture.profileWrites.add(name); fixture.profileAvatars.add(avatarUrl); } diff --git a/mobile/test/shared/widgets/buzz_setting_menu_test.dart b/mobile/test/shared/widgets/buzz_setting_menu_test.dart index ed4313f8ea..2616a5d3ae 100644 --- a/mobile/test/shared/widgets/buzz_setting_menu_test.dart +++ b/mobile/test/shared/widgets/buzz_setting_menu_test.dart @@ -1,6 +1,8 @@ import 'dart:ui' show CheckedState, Tristate; import 'package:flutter/material.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:buzz/shared/theme/theme.dart'; @@ -54,4 +56,103 @@ void main() { expect(data.flagsCollection.isEnabled, Tristate.isFalse); handle.dispose(); }); + testWidgets('iOS grouped choices use the native menu and update selection', ( + tester, + ) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + const channel = MethodChannel('buzz/navigation_glass/987'); + final calls = []; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(channel, ( + call, + ) async { + calls.add(call); + return null; + }); + addTearDown( + () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + channel, + null, + ), + ); + String? selected; + Widget nativeMenu({bool enabled = true, String value = 'high'}) => + MaterialApp( + home: Scaffold( + body: BuzzSettingMenu( + controlKey: const Key('native-menu'), + grouped: true, + enabled: enabled, + label: 'Thinking', + onSelected: (value) => selected = value, + options: const [ + BuzzSettingMenuOption(label: 'Low', value: 'low'), + BuzzSettingMenuOption(label: 'High', value: 'high'), + ], + value: value, + valueLabel: value, + ), + ), + ); + await tester.binding.setSurfaceSize(const Size(340, 800)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + await tester.pumpWidget(nativeMenu(enabled: false)); + final view = tester.widget(find.byType(UiKitView)); + expect(view.viewType, 'buzz/navigation_glass'); + expect(view.creationParams, containsPair('deferMenuActions', false)); + expect(view.creationParams, containsPair('fillWidth', true)); + expect(view.gestureRecognizers, isEmpty); + expect(find.byType(MenuAnchor), findsNothing); + expect( + view.creationParams, + containsPair('accessibilityLabel', 'Thinking: high'), + ); + expect( + ((view.creationParams as Map)['menu'] as List)[1], + containsPair('checked', true), + ); + view.onPlatformViewCreated!(987); + await tester.pump(); + Future choose(int index) async { + await tester.binding.defaultBinaryMessenger.handlePlatformMessage( + channel.name, + const StandardMethodCodec().encodeMethodCall( + MethodCall('menuAction', index), + ), + (_) {}, + ); + await tester.pump(); + } + + await tester.pumpWidget(nativeMenu()); + await tester.pump(); + final portraitWidth = tester.getSize(find.byType(UiKitView)).width; + await tester.binding.setSurfaceSize(const Size(600, 340)); + await tester.pump(); + expect( + tester.getSize(find.byType(UiKitView)).width, + greaterThan(portraitWidth), + ); + await choose(0); + expect(selected, 'low'); + await tester.pumpWidget(nativeMenu(value: 'low')); + await tester.pump(); + final content = + calls.lastWhere((call) => call.method == 'setContent').arguments as Map; + expect((content['menu'] as List)[0], containsPair('checked', true)); + await tester.pumpWidget(nativeMenu(enabled: false)); + await tester.pump(); + expect( + calls.lastWhere((call) => call.method == 'setAppearance').arguments, + containsPair('enabled', false), + ); + await choose(1); + expect(selected, 'low'); + await tester.pumpWidget(nativeMenu(value: 'low')); + await tester.pump(); + await choose(1); + expect(selected, 'high'); + await tester.pumpWidget(const SizedBox.shrink()); + debugDefaultTargetPlatformOverride = null; + }); } diff --git a/scripts/test-mobile-setting-menu.sh b/scripts/test-mobile-setting-menu.sh new file mode 100755 index 0000000000..59e79f8bac --- /dev/null +++ b/scripts/test-mobile-setting-menu.sh @@ -0,0 +1,21 @@ +#!/bin/bash +# Exercise the production UIKit settings menu on a booted iOS simulator. +set -euo pipefail +repo_root=$(cd "$(dirname "$0")/.." && pwd) +device=${1:?Pass a booted iOS simulator UDID} +flutter_root=$(sed -n 's/^FLUTTER_ROOT=//p' "$repo_root/mobile/ios/Flutter/Generated.xcconfig") +frameworks="$flutter_root/bin/cache/artifacts/engine/ios/Flutter.xcframework/ios-arm64_x86_64-simulator" +fixture=$(mktemp -d) +trap 'rm -r "$fixture"' EXIT +# Compile the production menu and invoke its actual UIKit UIActions. +cat "$repo_root/mobile/ios/Runner/NativeTabler.swift" \ + "$repo_root/mobile/ios/Runner/JumpToLatestGlassButton.swift" \ + "$repo_root/mobile/ios/NativeControlTests/SettingMenuSelectionTests.swift" > "$fixture/test.swift" +xcrun --sdk iphonesimulator swiftc -parse-as-library \ + -sdk "$(xcrun --sdk iphonesimulator --show-sdk-path)" \ + -target "$(uname -m)-apple-ios16.0-simulator" \ + -F "$frameworks" -framework Flutter \ + -Xlinker -rpath -Xlinker "$frameworks" \ + "$fixture/test.swift" \ + -o "$fixture/setting-menu-tests" +xcrun simctl spawn "$device" "$fixture/setting-menu-tests"