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_management_provider.dart';
|
||||
|
||||
part 'members_sheet/member_section.dart';
|
||||
|
||||
class MembersSheet extends HookConsumerWidget {
|
||||
final Channel channel;
|
||||
final String? currentPubkey;
|
||||
@@ -94,18 +96,19 @@ class MembersSheet extends HookConsumerWidget {
|
||||
key: const ValueKey('members-sheet-viewport'),
|
||||
constraints: BoxConstraints(maxHeight: 400 + bottomClearance),
|
||||
child: membersAsync.when(
|
||||
data: (_) => ListView(
|
||||
data: (_) => CustomScrollView(
|
||||
key: const ValueKey('members-sheet-list'),
|
||||
semanticChildCount: allMembers.length,
|
||||
shrinkWrap: true,
|
||||
padding: EdgeInsets.only(top: Grid.xxs, bottom: bottomClearance),
|
||||
children: [
|
||||
slivers: [
|
||||
const SliverToBoxAdapter(child: SizedBox(height: Grid.xxs)),
|
||||
if (people.isNotEmpty)
|
||||
SheetActionSection(
|
||||
_MemberSection(
|
||||
label: 'People · ${people.length}',
|
||||
dividerIndent: Grid.xs + 40 + Grid.xs,
|
||||
children: [
|
||||
for (final member in people)
|
||||
_MemberTile(
|
||||
itemCount: people.length,
|
||||
itemBuilder: (context, index) {
|
||||
final member = people[index];
|
||||
return _MemberTile(
|
||||
member: member,
|
||||
currentPubkey: currentPubkey,
|
||||
profile: userCache[member.pubkey.toLowerCase()],
|
||||
@@ -118,16 +121,17 @@ class MembersSheet extends HookConsumerWidget {
|
||||
onActivityTap: onMemberTap == null
|
||||
? null
|
||||
: () => onMemberTap!(context, member.pubkey),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
if (bots.isNotEmpty)
|
||||
SheetActionSection(
|
||||
_MemberSection(
|
||||
label: 'Agents · ${bots.length}',
|
||||
dividerIndent: Grid.xs + 40 + Grid.xs,
|
||||
children: [
|
||||
for (final bot in bots)
|
||||
_MemberTile(
|
||||
semanticIndexOffset: people.length,
|
||||
itemCount: bots.length,
|
||||
itemBuilder: (context, index) {
|
||||
final bot = bots[index];
|
||||
return _MemberTile(
|
||||
member: bot,
|
||||
currentPubkey: currentPubkey,
|
||||
profile: userCache[bot.pubkey.toLowerCase()],
|
||||
@@ -142,11 +146,12 @@ class MembersSheet extends HookConsumerWidget {
|
||||
typingBotPubkeys.contains(bot.pubkey.toLowerCase())
|
||||
? () => openActivity(bot)
|
||||
: null,
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
if (people.isEmpty && bots.isEmpty)
|
||||
Center(
|
||||
SliverToBoxAdapter(
|
||||
child: Center(
|
||||
child: Text(
|
||||
'No members found.',
|
||||
style: context.textTheme.bodySmall?.copyWith(
|
||||
@@ -154,6 +159,8 @@ class MembersSheet extends HookConsumerWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(child: SizedBox(height: bottomClearance)),
|
||||
],
|
||||
),
|
||||
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.pumpAndSettle();
|
||||
// Verify each group when it enters the lazy sheet viewport.
|
||||
expect(
|
||||
find.byWidgetPredicate(
|
||||
(widget) => widget is AppListCard && widget.label == 'People · 6',
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.text('People · 6'), findsOneWidget);
|
||||
await tester.drag(
|
||||
find.byKey(const ValueKey('members-sheet-list')),
|
||||
const Offset(0, -500),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
find.byWidgetPredicate(
|
||||
(widget) => widget is AppListCard && widget.label == 'Agents · 1',
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.text('Agents · 1'), findsOneWidget);
|
||||
expect(find.text('Agent'), findsOneWidget);
|
||||
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