Merge remote-tracking branch 'origin/main' into fix-agent-avatar-shape

Signed-off-by: Tom Brow <tomb@squareup.com>
This commit is contained in:
Tom Brow
2026-10-31 10:32:41 -07:00
28 changed files with 1776 additions and 340 deletions

View File

@@ -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")
}
}

View File

@@ -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)
}

View File

@@ -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,
),
),
),
),
],
),
),

View File

@@ -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,
),
),
),
],
),
],
);
}
}

View File

@@ -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<String?, String>((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<List<NostrEvent>> 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<String?> loadAgentInstructions(
String pubkey,
String owner,
String relayAuthor,
Future<List<NostrEvent>> 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,
),
);
}
}

View File

@@ -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<String>(
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<String>(
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<String>(
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 ?? <String>[])
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<String>(
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 ?? <String>[])
BuzzSettingMenuOption(label: value, value: value),
],
value: validEffort,
valueLabel: loading ? 'Loading…' : validEffort ?? 'Choose',
),
if (modelId != null && selected == null ||
effort != null && validEffort == null)

View File

@@ -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

View File

@@ -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,

View File

@@ -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(),

View File

@@ -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,

View File

@@ -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<ProfileAvatarDraft?> avatar;
final ValueChanged<String> onName;
final Future<void> Function() editAvatar;
final Future<void> 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'),
),
],
),
),
);
}
}

View File

@@ -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<ProfileAvatarDraft?> avatar;
final ValueChanged<String> onName;
final Future<void> Function() editAvatar;
final Future<void> 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<void> 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'),
),
],
);
}
}

View File

@@ -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<void> 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<void>.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!),
],
],
),
),

View File

@@ -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<http.Client>((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<String?, String>((
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 = <int>[];
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;
});

View File

@@ -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<T> {
@@ -28,6 +31,12 @@ class BuzzSettingMenu<T> 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<T> 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<T> 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,
),
],
),
),
),
),
),
),
),
),
),

View File

@@ -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<MethodChannel?>(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 = <String, Object>{
@@ -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<void>('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>(EagerGestureRecognizer.new),
if (eagerGestures)
Factory<EagerGestureRecognizer>(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) =>

View File

@@ -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,
),
);
}
}

View File

@@ -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<List<String>> 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<NostrEvent> channels,
List<NostrEvent> 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,
);
},
);
}

View File

@@ -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<AgentCard>(find.byType(AgentCard))
.map((card) => card.name),
['alpha', 'Zulu'],
);
profiles.put(UserProfile(pubkey: existing, displayName: 'aardvark'));
await tester.pumpAndSettle();
expect(
tester
.widgetList<AgentCard>(find.byType(AgentCard))
.map((card) => card.name),
['aardvark', 'alpha'],
);
});
}

View File

@@ -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<List<NostrEvent>> queryRelay(
List<NostrFilter> 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<List<NostrEvent>> 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<List<NostrEvent>> 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<AgentsHarness> 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(

View File

@@ -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 = <MethodCall>[];
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<AgentCard>(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<AgentCard>(find.byKey(ValueKey(existing))).isBestie,
isFalse,
);
expect(
tester.widget<AgentCard>(find.byKey(ValueKey(fakeAgent))).isBestie,
isTrue,
);
}
ScaffoldMessenger.of(list).clearSnackBars();
await tester.tap(find.byTooltip('Close agents'));

View File

@@ -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();

View File

@@ -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<void>();
final refreshing = controller.run(() => refresh.future);
await tester.pump();
expect(find.text('Deleting…'), findsNothing);
expect(find.text('Delete agent'), findsOneWidget);
expect(tester.widget<AppListRow>(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'));

View File

@@ -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<PlayingAvatarImage>(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<PlayingAvatarImage>(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<TextField>(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);

View File

@@ -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<FilledButton>(find.byType(FilledButton)).onPressed,
isNull,
);
await tester.binding.handlePopRoute();
await tester.pumpAndSettle();
expect(
tester.widget<TextField>(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);

View File

@@ -71,6 +71,7 @@ class HostedJourneyFixture {
final socketHeaders = <Map<String, String>>[];
bool offerProfile = true;
bool profileUnavailable = false;
int profileSaveFailures = 0;
bool rejectList = false;
int saveFailures = 0;
final knownSaved = <String>{};
@@ -518,11 +519,19 @@ class _Profile extends ProfileNotifier {
@override
Future<void> updateDisplayName(String name) async {
if (fixture.profileSaveFailures > 0) {
fixture.profileSaveFailures--;
throw StateError('profile save unavailable');
}
fixture.profileWrites.add(name);
}
@override
Future<void> 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);
}

View File

@@ -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 = <MethodCall>[];
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<String>(
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<UiKitView>(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<void> 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;
});
}

View File

@@ -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"