mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
fix(mobile): lazily lay out channel member groups (#8437)
Opening a large channel member sheet mounted and laid out every person in a grouped column. A captured Debug simulator entrance spent 242 ms in the UI callback, including 223 ms in layout. Build each person and agent as a lazy sliver row while preserving the grouped cards, names, statuses, management menus, and activity actions. Screen-reader row indices continue across both groups. A separate formatting-only commit fixes an existing carousel-test formatter discrepancy. No duplicate member-sheet performance PR or issue found. ### Validation - Added a 200-member widget regression: the original mounts 100 people initially; the fix mounts fewer than 20 nearby rows and reaches the final person and agent. - Added grouping, compact sizing, empty state, member tap identity, accessible indices, and scrolled member/agent menu checks. Existing channel-details “See all” navigation test passes. - Mobile formatting, static analysis, and the full mobile test suite passed on the committed tree. - Synthetic before/after rendering is pixel-identical at the same viewport. - Populated native iOS 26.5 simulator workflow: open members, scroll through people and agents to the end, then close. Final installed build passed this workflow. Debug, accessibility automation, profiler overhead, different Flutter versions, and shared-host load limit timing conclusions. This removes eager offscreen layout; it does not establish a controlled end-to-end speedup or release-device performance. ### Visual check Synthetic member sheet; before and after are pixel-identical at this viewport.  --------- Signed-off-by: Tom Brow <tomb@block.xyz>
This commit is contained in:
@@ -19,6 +19,8 @@ import 'channel.dart';
|
|||||||
import 'channel_identity_names_provider.dart';
|
import 'channel_identity_names_provider.dart';
|
||||||
import 'channel_management_provider.dart';
|
import 'channel_management_provider.dart';
|
||||||
|
|
||||||
|
part 'members_sheet/member_section.dart';
|
||||||
|
|
||||||
class MembersSheet extends HookConsumerWidget {
|
class MembersSheet extends HookConsumerWidget {
|
||||||
final Channel channel;
|
final Channel channel;
|
||||||
final String? currentPubkey;
|
final String? currentPubkey;
|
||||||
@@ -94,66 +96,71 @@ class MembersSheet extends HookConsumerWidget {
|
|||||||
key: const ValueKey('members-sheet-viewport'),
|
key: const ValueKey('members-sheet-viewport'),
|
||||||
constraints: BoxConstraints(maxHeight: 400 + bottomClearance),
|
constraints: BoxConstraints(maxHeight: 400 + bottomClearance),
|
||||||
child: membersAsync.when(
|
child: membersAsync.when(
|
||||||
data: (_) => ListView(
|
data: (_) => CustomScrollView(
|
||||||
key: const ValueKey('members-sheet-list'),
|
key: const ValueKey('members-sheet-list'),
|
||||||
|
semanticChildCount: allMembers.length,
|
||||||
shrinkWrap: true,
|
shrinkWrap: true,
|
||||||
padding: EdgeInsets.only(top: Grid.xxs, bottom: bottomClearance),
|
slivers: [
|
||||||
children: [
|
const SliverToBoxAdapter(child: SizedBox(height: Grid.xxs)),
|
||||||
if (people.isNotEmpty)
|
if (people.isNotEmpty)
|
||||||
SheetActionSection(
|
_MemberSection(
|
||||||
label: 'People · ${people.length}',
|
label: 'People · ${people.length}',
|
||||||
dividerIndent: Grid.xs + 40 + Grid.xs,
|
itemCount: people.length,
|
||||||
children: [
|
itemBuilder: (context, index) {
|
||||||
for (final member in people)
|
final member = people[index];
|
||||||
_MemberTile(
|
return _MemberTile(
|
||||||
member: member,
|
member: member,
|
||||||
currentPubkey: currentPubkey,
|
currentPubkey: currentPubkey,
|
||||||
profile: userCache[member.pubkey.toLowerCase()],
|
profile: userCache[member.pubkey.toLowerCase()],
|
||||||
canManage: canManage,
|
canManage: canManage,
|
||||||
isSelf:
|
isSelf:
|
||||||
member.pubkey.toLowerCase() ==
|
member.pubkey.toLowerCase() ==
|
||||||
currentPubkey?.toLowerCase(),
|
currentPubkey?.toLowerCase(),
|
||||||
channelId: channel.id,
|
channelId: channel.id,
|
||||||
userStatus: statusCache[member.pubkey.toLowerCase()],
|
userStatus: statusCache[member.pubkey.toLowerCase()],
|
||||||
onActivityTap: onMemberTap == null
|
onActivityTap: onMemberTap == null
|
||||||
? null
|
? null
|
||||||
: () => onMemberTap!(context, member.pubkey),
|
: () => onMemberTap!(context, member.pubkey),
|
||||||
),
|
);
|
||||||
],
|
},
|
||||||
),
|
),
|
||||||
if (bots.isNotEmpty)
|
if (bots.isNotEmpty)
|
||||||
SheetActionSection(
|
_MemberSection(
|
||||||
label: 'Agents · ${bots.length}',
|
label: 'Agents · ${bots.length}',
|
||||||
dividerIndent: Grid.xs + 40 + Grid.xs,
|
semanticIndexOffset: people.length,
|
||||||
children: [
|
itemCount: bots.length,
|
||||||
for (final bot in bots)
|
itemBuilder: (context, index) {
|
||||||
_MemberTile(
|
final bot = bots[index];
|
||||||
member: bot,
|
return _MemberTile(
|
||||||
currentPubkey: currentPubkey,
|
member: bot,
|
||||||
profile: userCache[bot.pubkey.toLowerCase()],
|
currentPubkey: currentPubkey,
|
||||||
canManage: canManage,
|
profile: userCache[bot.pubkey.toLowerCase()],
|
||||||
isSelf: false,
|
canManage: canManage,
|
||||||
channelId: channel.id,
|
isSelf: false,
|
||||||
isWorking: typingBotPubkeys.contains(
|
channelId: channel.id,
|
||||||
bot.pubkey.toLowerCase(),
|
isWorking: typingBotPubkeys.contains(
|
||||||
),
|
bot.pubkey.toLowerCase(),
|
||||||
onViewActivity: () => openActivity(bot),
|
|
||||||
onActivityTap:
|
|
||||||
typingBotPubkeys.contains(bot.pubkey.toLowerCase())
|
|
||||||
? () => openActivity(bot)
|
|
||||||
: null,
|
|
||||||
),
|
),
|
||||||
],
|
onViewActivity: () => openActivity(bot),
|
||||||
|
onActivityTap:
|
||||||
|
typingBotPubkeys.contains(bot.pubkey.toLowerCase())
|
||||||
|
? () => openActivity(bot)
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
if (people.isEmpty && bots.isEmpty)
|
if (people.isEmpty && bots.isEmpty)
|
||||||
Center(
|
SliverToBoxAdapter(
|
||||||
child: Text(
|
child: Center(
|
||||||
'No members found.',
|
child: Text(
|
||||||
style: context.textTheme.bodySmall?.copyWith(
|
'No members found.',
|
||||||
color: context.colors.onSurfaceVariant,
|
style: context.textTheme.bodySmall?.copyWith(
|
||||||
|
color: context.colors.onSurfaceVariant,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
SliverToBoxAdapter(child: SizedBox(height: bottomClearance)),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
loading: () => const Center(
|
loading: () => const Center(
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
part of '../members_sheet.dart';
|
||||||
|
|
||||||
|
/// The member card is split into independently laid-out rows so a large group
|
||||||
|
/// cannot force every offscreen member to mount during the sheet entrance.
|
||||||
|
class _MemberSection extends StatelessWidget {
|
||||||
|
const _MemberSection({
|
||||||
|
required this.label,
|
||||||
|
required this.itemCount,
|
||||||
|
required this.itemBuilder,
|
||||||
|
this.semanticIndexOffset = 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String label;
|
||||||
|
final int itemCount;
|
||||||
|
final IndexedWidgetBuilder itemBuilder;
|
||||||
|
final int semanticIndexOffset;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => SliverPadding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: Grid.xxs),
|
||||||
|
sliver: SliverMainAxisGroup(
|
||||||
|
slivers: [
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.only(left: Grid.half, bottom: Grid.xxs),
|
||||||
|
child: Text(
|
||||||
|
label,
|
||||||
|
style: context.textTheme.labelMedium?.copyWith(
|
||||||
|
color: context.colors.onSurfaceVariant,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SliverList(
|
||||||
|
delegate: SliverChildBuilderDelegate(
|
||||||
|
(context, index) => Material(
|
||||||
|
color: context.colors.surfaceContainerHighest,
|
||||||
|
borderRadius: BorderRadius.vertical(
|
||||||
|
top: index == 0
|
||||||
|
? const Radius.circular(Radii.container)
|
||||||
|
: Radius.zero,
|
||||||
|
bottom: index == itemCount - 1
|
||||||
|
? const Radius.circular(Radii.container)
|
||||||
|
: Radius.zero,
|
||||||
|
),
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
|
child: ListTileTheme.merge(
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: Grid.xs),
|
||||||
|
minLeadingWidth: 22,
|
||||||
|
horizontalTitleGap: Grid.xs,
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
if (index > 0)
|
||||||
|
Divider(
|
||||||
|
height: 1,
|
||||||
|
thickness: 1,
|
||||||
|
indent: Grid.xs + 40 + Grid.xs,
|
||||||
|
endIndent: Grid.xs,
|
||||||
|
color: context.colors.onSurface.withValues(alpha: 0.07),
|
||||||
|
),
|
||||||
|
itemBuilder(context, index),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
childCount: itemCount,
|
||||||
|
semanticIndexOffset: semanticIndexOffset,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3497,23 +3497,13 @@ void main() {
|
|||||||
await tester.tap(seeAllRow);
|
await tester.tap(seeAllRow);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
// Verify each group when it enters the lazy sheet viewport.
|
// Verify each group when it enters the lazy sheet viewport.
|
||||||
expect(
|
expect(find.text('People · 6'), findsOneWidget);
|
||||||
find.byWidgetPredicate(
|
|
||||||
(widget) => widget is AppListCard && widget.label == 'People · 6',
|
|
||||||
),
|
|
||||||
findsOneWidget,
|
|
||||||
);
|
|
||||||
await tester.drag(
|
await tester.drag(
|
||||||
find.byKey(const ValueKey('members-sheet-list')),
|
find.byKey(const ValueKey('members-sheet-list')),
|
||||||
const Offset(0, -500),
|
const Offset(0, -500),
|
||||||
);
|
);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(
|
expect(find.text('Agents · 1'), findsOneWidget);
|
||||||
find.byWidgetPredicate(
|
|
||||||
(widget) => widget is AppListCard && widget.label == 'Agents · 1',
|
|
||||||
),
|
|
||||||
findsOneWidget,
|
|
||||||
);
|
|
||||||
expect(find.text('Agent'), findsOneWidget);
|
expect(find.text('Agent'), findsOneWidget);
|
||||||
expect(find.text('Bot'), findsNothing);
|
expect(find.text('Bot'), findsNothing);
|
||||||
});
|
});
|
||||||
|
|||||||
213
mobile/test/features/channels/members_sheet_test.dart
Normal file
213
mobile/test/features/channels/members_sheet_test.dart
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
import 'package:buzz/features/channels/agent_activity/working_bots_provider.dart';
|
||||||
|
import 'package:buzz/features/channels/channel.dart';
|
||||||
|
import 'package:buzz/features/channels/channel_identity_names_provider.dart';
|
||||||
|
import 'package:buzz/features/channels/channel_management_provider.dart';
|
||||||
|
import 'package:buzz/features/channels/members_sheet.dart';
|
||||||
|
import 'package:buzz/features/profile/user_status.dart';
|
||||||
|
import 'package:buzz/features/profile/user_status_cache_provider.dart';
|
||||||
|
import 'package:buzz/shared/identity_names/identity_names.dart';
|
||||||
|
import 'package:buzz/shared/profile/user_cache_provider.dart';
|
||||||
|
import 'package:buzz/shared/profile/user_profile.dart';
|
||||||
|
import 'package:buzz/shared/theme/theme.dart';
|
||||||
|
import 'package:buzz/shared/theme/buzz_icons.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
|
||||||
|
class _Cache extends UserCacheNotifier {
|
||||||
|
@override
|
||||||
|
Map<String, UserProfile> build() => {};
|
||||||
|
@override
|
||||||
|
Future<bool> preload(List<String> pubkeys) async => true;
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Statuses extends UserStatusCacheNotifier {
|
||||||
|
@override
|
||||||
|
Map<String, UserStatus?> build() => {};
|
||||||
|
@override
|
||||||
|
void track(List<String> pubkeys) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _key(int index) => index.toRadixString(16).padLeft(64, '0');
|
||||||
|
|
||||||
|
Future<void> _pump(
|
||||||
|
WidgetTester tester,
|
||||||
|
List<ChannelMember> members, {
|
||||||
|
ValueChanged<String>? onTap,
|
||||||
|
}) async {
|
||||||
|
final names = IdentityNameSources.empty.scope(
|
||||||
|
members.map((m) => m.pubkey),
|
||||||
|
fallbackNames: {for (final m in members) m.pubkey: m.displayName!},
|
||||||
|
);
|
||||||
|
await tester.pumpWidget(
|
||||||
|
ProviderScope(
|
||||||
|
overrides: [
|
||||||
|
channelMembersProvider('channel').overrideWith((ref) async => members),
|
||||||
|
channelIdentityNamesProvider('channel').overrideWith((ref) => names),
|
||||||
|
userCacheProvider.overrideWith(_Cache.new),
|
||||||
|
userStatusCacheProvider.overrideWith(_Statuses.new),
|
||||||
|
workingBotPubkeysProvider('channel').overrideWith((ref) => {}),
|
||||||
|
],
|
||||||
|
child: MaterialApp(
|
||||||
|
theme: AppTheme.light(),
|
||||||
|
home: Scaffold(
|
||||||
|
body: Align(
|
||||||
|
alignment: Alignment.bottomCenter,
|
||||||
|
child: MembersSheet(
|
||||||
|
channel: Channel(
|
||||||
|
id: 'channel',
|
||||||
|
name: 'Test',
|
||||||
|
channelType: 'stream',
|
||||||
|
visibility: 'open',
|
||||||
|
description: '',
|
||||||
|
createdBy: _key(0),
|
||||||
|
createdAt: DateTime(2026),
|
||||||
|
memberCount: members.length,
|
||||||
|
),
|
||||||
|
currentPubkey: _key(0),
|
||||||
|
onMemberTap: (_, key) => onTap?.call(key),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
}
|
||||||
|
|
||||||
|
List<ChannelMember> _roster(int people, int bots) => [
|
||||||
|
for (var i = 0; i < people + bots; i++)
|
||||||
|
ChannelMember(
|
||||||
|
pubkey: _key(i),
|
||||||
|
role: i == 0
|
||||||
|
? 'owner'
|
||||||
|
: i < people
|
||||||
|
? 'member'
|
||||||
|
: 'bot',
|
||||||
|
joinedAt: DateTime(2026),
|
||||||
|
displayName: i < people ? 'Person $i' : 'Agent $i',
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
testWidgets('large groups mount nearby rows and can reach the final agent', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
tester.view.physicalSize = const Size(390, 844);
|
||||||
|
tester.view.devicePixelRatio = 1;
|
||||||
|
addTearDown(tester.view.resetPhysicalSize);
|
||||||
|
addTearDown(tester.view.resetDevicePixelRatio);
|
||||||
|
await _pump(tester, _roster(100, 100));
|
||||||
|
expect(find.text('People · 100'), findsOneWidget);
|
||||||
|
expect(find.text('You'), findsOneWidget);
|
||||||
|
expect(find.byType(ListTile).evaluate().length, lessThan(20));
|
||||||
|
expect(find.text('Person 99'), findsNothing);
|
||||||
|
final scrollable = find.descendant(
|
||||||
|
of: find.byKey(const ValueKey('members-sheet-list')),
|
||||||
|
matching: find.byType(Scrollable),
|
||||||
|
);
|
||||||
|
await tester.scrollUntilVisible(
|
||||||
|
find.text('Person 99'),
|
||||||
|
300,
|
||||||
|
scrollable: scrollable,
|
||||||
|
maxScrolls: 100,
|
||||||
|
);
|
||||||
|
expect(find.text('Person 99'), findsOneWidget);
|
||||||
|
await tester.scrollUntilVisible(
|
||||||
|
find.text('Agent 199'),
|
||||||
|
300,
|
||||||
|
scrollable: scrollable,
|
||||||
|
maxScrolls: 100,
|
||||||
|
);
|
||||||
|
expect(find.text('Agent 199'), findsOneWidget);
|
||||||
|
expect(find.byType(ListTile).evaluate().length, lessThan(20));
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('short list keeps grouping, self label and person tap identity', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
String? selected;
|
||||||
|
final semantics = tester.ensureSemantics();
|
||||||
|
|
||||||
|
await _pump(tester, _roster(2, 1), onTap: (key) => selected = key);
|
||||||
|
expect(find.text('People · 2'), findsOneWidget);
|
||||||
|
expect(find.text('Agents · 1'), findsOneWidget);
|
||||||
|
final agentIndex = tester.widget<IndexedSemantics>(
|
||||||
|
find.ancestor(
|
||||||
|
of: find.text('Agent 2'),
|
||||||
|
matching: find.byType(IndexedSemantics),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(agentIndex.index, 2);
|
||||||
|
expect(
|
||||||
|
tester
|
||||||
|
.widget<CustomScrollView>(
|
||||||
|
find.byKey(const ValueKey('members-sheet-list')),
|
||||||
|
)
|
||||||
|
.semanticChildCount,
|
||||||
|
3,
|
||||||
|
);
|
||||||
|
expect(find.text('You'), findsOneWidget);
|
||||||
|
expect(find.bySemanticsLabel(RegExp('Person 1')), findsWidgets);
|
||||||
|
await tester.tap(find.text('Person 1'));
|
||||||
|
expect(selected, _key(1));
|
||||||
|
semantics.dispose();
|
||||||
|
expect(
|
||||||
|
tester.getSize(find.byKey(const ValueKey('members-sheet-list'))).height,
|
||||||
|
lessThan(400),
|
||||||
|
);
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('owner actions stay available after lazy scrolling', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await _pump(tester, _roster(40, 1));
|
||||||
|
final scrollable = find.descendant(
|
||||||
|
of: find.byKey(const ValueKey('members-sheet-list')),
|
||||||
|
matching: find.byType(Scrollable),
|
||||||
|
);
|
||||||
|
await tester.scrollUntilVisible(
|
||||||
|
find.text('Person 39'),
|
||||||
|
300,
|
||||||
|
scrollable: scrollable,
|
||||||
|
maxScrolls: 50,
|
||||||
|
);
|
||||||
|
final row = find.ancestor(
|
||||||
|
of: find.text('Person 39'),
|
||||||
|
matching: find.byType(ListTile),
|
||||||
|
);
|
||||||
|
await tester.tap(
|
||||||
|
find.descendant(of: row, matching: find.byIcon(BuzzIcons.ellipsis)),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('Role'), findsOneWidget);
|
||||||
|
expect(find.text('Remove from channel'), findsOneWidget);
|
||||||
|
// Inspect only: do not invoke a membership mutation.
|
||||||
|
Navigator.of(tester.element(find.text('Role'))).pop();
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.scrollUntilVisible(
|
||||||
|
find.text('Agent 40'),
|
||||||
|
100,
|
||||||
|
scrollable: scrollable,
|
||||||
|
);
|
||||||
|
final agent = find.ancestor(
|
||||||
|
of: find.text('Agent 40'),
|
||||||
|
matching: find.byType(ListTile),
|
||||||
|
);
|
||||||
|
await tester.tap(
|
||||||
|
find.descendant(of: agent, matching: find.byIcon(BuzzIcons.ellipsis)),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('View activity'), findsOneWidget);
|
||||||
|
expect(find.text('Role'), findsNothing);
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('empty roster retains empty message', (tester) async {
|
||||||
|
await _pump(tester, []);
|
||||||
|
expect(find.text('No members found.'), findsOneWidget);
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user