mirror of
https://github.com/block/buzz.git
synced 2026-11-12 20:31:12 +00:00
Merge remote-tracking branch 'origin/main' into fix-agent-avatar-shape
Signed-off-by: Tom Brow <tomb@squareup.com>
This commit is contained in:
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
63
mobile/lib/features/agents/agent_identity_sections.dart
Normal file
63
mobile/lib/features/agents/agent_identity_sections.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
143
mobile/lib/features/agents/agent_instructions.dart
Normal file
143
mobile/lib/features/agents/agent_instructions.dart
Normal 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
142
mobile/lib/features/profile/profile_setup_page/form.dart
Normal file
142
mobile/lib/features/profile/profile_setup_page/form.dart
Normal 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'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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!),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
54
mobile/lib/shared/relay/relay_author_provider.dart
Normal file
54
mobile/lib/shared/relay/relay_author_provider.dart
Normal 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;
|
||||
});
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
111
mobile/lib/shared/widgets/profile_action_chip.dart
Normal file
111
mobile/lib/shared/widgets/profile_action_chip.dart
Normal 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
202
mobile/test/features/agents/agent_instructions_test.dart
Normal file
202
mobile/test/features/agents/agent_instructions_test.dart
Normal 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,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
40
mobile/test/features/agents/agent_ordering_test.dart
Normal file
40
mobile/test/features/agents/agent_ordering_test.dart
Normal 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'],
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
21
scripts/test-mobile-setting-menu.sh
Executable file
21
scripts/test-mobile-setting-menu.sh
Executable 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"
|
||||
Reference in New Issue
Block a user