mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
🤖 ## Summary In the mobile app, anyone who hasn't set a display name shows up as a raw 64-character hex key (e.g. `3a5d4f9c…`) — unreadable, and unrecognizable as the same identity across screens. Profile and Settings also let you copy that raw hex. Nostr public keys have a standard readable form — `npub1…`, the same encoding other Nostr apps and our desktop app already display. This PR makes every mobile identity surface render npub instead: - **Unnamed people everywhere** — message and thread authors, reactions, typing indicators, member lists, channel details, DM headers and tiles, inbox, search, forum cards, Pulse notes and reply context, mention suggestions, and invite rows — now show a compact npub label: first 8 + last 4 characters of the full npub joined by an ellipsis (`npub1abcd…wxyz`), the same truncation desktop uses. Previously these showed truncated raw hex. - **DM fallback avatars and blank names** — 1:1 DM tiles and headers key their fallback avatar to the same non-self counterpart the label names, including self-first participant order; a self-DM keeps its hex-key-derived initial. Blank or whitespace-only display names fall back to the compact npub instead of rendering empty, while nonblank authored names render verbatim (padding included). - **Profile sheet → "Copy public key"** now copies the full canonical npub — never raw hex. When the identity string isn't a valid public key, the copy tile is disabled, so a malformed key never reaches the clipboard. - **Settings → Identity (pubkey)** displays and copies the full npub; an invalid identity reads "Identity unavailable" with copy disabled. - **Invalid identities never leak truncated raw hex** into the UI anywhere — they render a neutral "Unknown identity" label. - **Unchanged on purpose:** display names and verified handles (NIP-05 — the `name@domain` badge) still render as before. Unnamed avatars keep distinct per-key initials, derived from the underlying hex key rather than the npub — otherwise every unnamed key would render the same "N" initial. Event IDs are not public keys, so they keep their hex truncation (in Pulse's "Replying to", the parent author shows npub while an event-id fallback still shows hex). The nevent share link, private keys, and internal hex storage are untouched. Inputs that accept a key (invite/member entry) accept both hex and npub and keep working in hex internally. ### Related issue N/A. Searched open issues/PRs for npub identity display on mobile — closest related: none found. Desktop's parallel npub standardization lives in the stacked desktop PRs (#7488 foundation, #7489 controls, #7495 display surfaces); this is the independent mobile slice (based directly on `main`, not on those branches). ### Testing At exact head `5a620e420a1fd57d9d8011ac26434eed32fcf765` (base: `main` `44316ff72`; 40 files, +1,345/−154): - Full mobile suite: 2,098 tests passing (`cd mobile && flutter test`); `flutter analyze` clean; `dart format --set-exit-if-changed .` clean — the same checks CI runs. - Widget/unit coverage at production seams: compact labels and hex-keyed avatar initials for DM headers/tiles, member rows, mention suggestions, and Pulse reply context; DM fallback avatars keyed to the labeled counterpart (self-first order and self-DMs); blank/whitespace display-name npub fallback with nonblank authored labels verbatim, including the Activity inbox sender and profile-sheet heading (each with its own empty/whitespace production-seam regression); full-npub copy and disabled-copy semantics in profile and settings; invalid-key suppression; and hex↔npub input round-trips. Verified via unit and widget tests — no device/simulator validation is claimed. ### Task provenance Buzz channel: `1f0e4a3d-7e01-4efe-bb16-843b357f85c9` Task: buzz://message?channel=1f0e4a3d-7e01-4efe-bb16-843b357f85c9&id=86b34eb4bd84a1472419e9af22636c011c0fe273e3c196f967d7a36996e149b6 --------- Signed-off-by: Logan Johnson <loganj@squareup.com> Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
2759 lines
85 KiB
Dart
2759 lines
85 KiB
Dart
import 'dart:async';
|
||
import 'dart:math';
|
||
|
||
import 'package:flutter/foundation.dart';
|
||
import 'package:flutter/material.dart';
|
||
import 'package:flutter/services.dart';
|
||
import 'package:flutter_test/flutter_test.dart';
|
||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||
import 'package:hooks_riverpod/misc.dart';
|
||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||
import 'package:buzz/features/channels/channel.dart';
|
||
import 'package:buzz/features/channels/channel_management_provider.dart';
|
||
import 'package:buzz/features/channels/channel_sections/channel_sections_provider.dart';
|
||
import 'package:buzz/features/channels/channel_sections/channel_sections_storage.dart';
|
||
import 'package:buzz/features/channels/channels_page.dart';
|
||
import 'package:buzz/features/channels/channels_provider.dart';
|
||
import 'package:buzz/shared/read_state/read_state_provider.dart';
|
||
import 'package:buzz/features/channels/unread_badge/observed_unread_event.dart';
|
||
import 'package:buzz/features/profile/profile_avatar.dart';
|
||
import 'package:buzz/features/profile/profile_provider.dart';
|
||
import 'package:buzz/shared/profile/user_profile.dart';
|
||
import 'package:buzz/shared/utils/string_utils.dart';
|
||
import 'package:buzz/shared/auth/auth.dart';
|
||
import 'package:buzz/shared/community/community_icon_provider.dart';
|
||
import 'package:buzz/shared/relay/relay.dart';
|
||
import 'package:buzz/shared/theme/theme.dart';
|
||
import 'package:buzz/shared/widgets/avatar_image.dart';
|
||
import 'package:buzz/shared/widgets/buzz_loading_indicator.dart';
|
||
import 'package:buzz/shared/widgets/frosted_app_bar.dart';
|
||
import 'package:buzz/shared/widgets/masked_avatar_badge.dart';
|
||
import 'package:buzz/shared/widgets/skeleton.dart';
|
||
|
||
void main() {
|
||
Widget buildTestable({
|
||
required List<Override> overrides,
|
||
bool previewDirectory = false,
|
||
double keyboardInset = 0,
|
||
bool disableAnimations = false,
|
||
double bottomPadding = 0,
|
||
Map<String, String?> communityIcons = const {},
|
||
ValueChanged<String>? onCommunityIconLoad,
|
||
TextScaler textScaler = TextScaler.noScaling,
|
||
Gradient? topSectionGradient,
|
||
ValueChanged<double>? onSettingsTransitionProgress,
|
||
ValueListenable<int>? tabReselection,
|
||
_FakeProfileNotifier? profile,
|
||
}) {
|
||
return ProviderScope(
|
||
overrides: [
|
||
// Provide a fake profile and presence so the avatar doesn't hit the
|
||
// network. [profile] swaps the current user for tests keyed to a
|
||
// different identity (the page reads its pubkey from profileProvider).
|
||
profileProvider.overrideWith(() => profile ?? _FakeProfileNotifier()),
|
||
presenceProvider.overrideWith(() => _FakePresenceNotifier()),
|
||
communityIconProvider.overrideWith((ref, relayUrl) async {
|
||
onCommunityIconLoad?.call(relayUrl);
|
||
return communityIcons[relayUrl];
|
||
}),
|
||
dmDirectoryPreviewEnabledProvider.overrideWith(
|
||
(ref) => previewDirectory,
|
||
),
|
||
...overrides,
|
||
],
|
||
child: MaterialApp(
|
||
theme: AppTheme.light(topSectionGradient: topSectionGradient),
|
||
builder: (context, child) => MediaQuery(
|
||
data: MediaQuery.of(context).copyWith(
|
||
disableAnimations: disableAnimations,
|
||
textScaler: textScaler,
|
||
padding: EdgeInsets.only(bottom: bottomPadding),
|
||
viewInsets: EdgeInsets.only(bottom: keyboardInset),
|
||
),
|
||
child: child!,
|
||
),
|
||
home: Stack(
|
||
children: [
|
||
ChannelsPage(
|
||
settingsPageBuilder: _buildSettingsPage,
|
||
onSettingsTransitionProgress:
|
||
onSettingsTransitionProgress ?? (_) {},
|
||
tabReselection: tabReselection,
|
||
),
|
||
const Positioned.fill(
|
||
child: ChannelQuickActionsLauncher(
|
||
visible: true,
|
||
navigationBarHeight: 60,
|
||
navigationBarBottomGap: 12,
|
||
navigationBarWidth: 218,
|
||
systemBottomInset: 0,
|
||
rightInset: 16,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
final testChannels = [
|
||
Channel(
|
||
id: '1',
|
||
name: 'general',
|
||
channelType: 'stream',
|
||
visibility: 'open',
|
||
description: 'General discussion',
|
||
createdBy: 'abc',
|
||
createdAt: DateTime(2025),
|
||
memberCount: 10,
|
||
isMember: true,
|
||
),
|
||
Channel(
|
||
id: '2',
|
||
name: 'design-forum',
|
||
channelType: 'forum',
|
||
visibility: 'open',
|
||
description: 'Discuss designs',
|
||
createdBy: 'abc',
|
||
createdAt: DateTime(2025),
|
||
memberCount: 3,
|
||
isMember: true,
|
||
),
|
||
Channel(
|
||
id: '3',
|
||
name: 'DM',
|
||
channelType: 'dm',
|
||
visibility: 'open',
|
||
description: 'Direct message',
|
||
createdBy: 'abc',
|
||
createdAt: DateTime(2025),
|
||
memberCount: 2,
|
||
participants: const ['Alice', 'Test'],
|
||
participantPubkeys: const ['alice', 'aabb'],
|
||
isMember: true,
|
||
),
|
||
];
|
||
|
||
testWidgets('shows grouped channel list when data loads', (tester) async {
|
||
// Valid fixture keys whose npub encodings were verified against the
|
||
// NIP-19 codec independently of the code under test.
|
||
const a11ce =
|
||
'a11ce00000000000000000000000000000000000000000000000000000000000';
|
||
const b0b =
|
||
'b0b0000000000000000000000000000000000000000000000000000000000000';
|
||
final unnamedDm = Channel(
|
||
id: 'dm-unnamed',
|
||
name: 'DM',
|
||
channelType: 'dm',
|
||
visibility: 'open',
|
||
description: 'Direct message',
|
||
createdBy: 'aabb',
|
||
createdAt: DateTime(2025),
|
||
memberCount: 2,
|
||
participants: [shortPubkey(a11ce), 'Test'],
|
||
participantPubkeys: const [a11ce, 'aabb'],
|
||
isMember: true,
|
||
);
|
||
final groupDm = Channel(
|
||
id: 'dm-group',
|
||
name: 'Group DM',
|
||
channelType: 'dm',
|
||
visibility: 'open',
|
||
description: 'Direct message',
|
||
createdBy: 'aabb',
|
||
createdAt: DateTime(2025),
|
||
memberCount: 3,
|
||
participants: [shortPubkey(a11ce), shortPubkey(b0b), 'Test'],
|
||
participantPubkeys: const [a11ce, b0b, 'aabb'],
|
||
isMember: true,
|
||
);
|
||
await tester.pumpWidget(
|
||
buildTestable(
|
||
overrides: [
|
||
channelsProvider.overrideWith(
|
||
() => _FakeNotifier([...testChannels, unnamedDm, groupDm]),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
await tester.pumpAndSettle();
|
||
|
||
expect(find.text('general'), findsOneWidget);
|
||
expect(find.text('design-forum'), findsNothing);
|
||
expect(find.text('Alice'), findsOneWidget);
|
||
expect(find.text('Channels'), findsOneWidget);
|
||
expect(find.text('FORUMS'), findsNothing);
|
||
expect(find.text('DMs'), findsOneWidget);
|
||
expect(find.text('Community'), findsOneWidget);
|
||
expect(find.byTooltip('Create or start conversation'), findsOneWidget);
|
||
expect(find.byTooltip('Channels options'), findsOneWidget);
|
||
expect(find.byIcon(LucideIcons.ellipsisVertical), findsWidgets);
|
||
expect(find.byIcon(LucideIcons.arrowUpDown), findsNothing);
|
||
expect(find.byTooltip('DMs options'), findsOneWidget);
|
||
|
||
// DM identity display: the unnamed counterpart tile renders its
|
||
// compact npub label, but its avatar initial stays keyed to the hex
|
||
// public key — never the `N` the npub label starts with. The named
|
||
// tile keeps its authored initial from the positional participant
|
||
// label even without a cached profile.
|
||
expect(find.text(shortPubkey(a11ce)), findsOneWidget);
|
||
expect(_dmTileAvatarInitial(tester, shortPubkey(a11ce)), 'A');
|
||
expect(_dmTileAvatarInitial(tester, 'Alice'), 'A');
|
||
// A multi-counterpart DM still takes the group count badge — the
|
||
// single-counterpart avatar above is not shared with it.
|
||
final groupTile = _dmTileFor('${shortPubkey(a11ce)}, ${shortPubkey(b0b)}');
|
||
expect(
|
||
find.descendant(of: groupTile, matching: find.byType(AvatarImage)),
|
||
findsNothing,
|
||
);
|
||
expect(
|
||
find.descendant(of: groupTile, matching: find.text('2')),
|
||
findsOneWidget,
|
||
);
|
||
|
||
await tester.tap(find.byTooltip('Channels options'));
|
||
await tester.pumpAndSettle();
|
||
expect(find.text('Sort: Recent'), findsOneWidget);
|
||
expect(find.text('Sort: A–Z'), findsOneWidget);
|
||
final popover = find.byKey(const ValueKey('sort-popover-Channels'));
|
||
expect(popover, findsOneWidget);
|
||
expect(
|
||
find.descendant(of: popover, matching: find.byType(PopupMenuDivider)),
|
||
findsNothing,
|
||
);
|
||
final selectedCheck = find.byKey(const ValueKey('sort-selected-check'));
|
||
expect(selectedCheck, findsOneWidget);
|
||
expect(
|
||
tester.getCenter(selectedCheck).dx,
|
||
greaterThan(tester.getCenter(find.text('Sort: A–Z')).dx),
|
||
);
|
||
|
||
for (final label in ['general', 'Alice']) {
|
||
final text = tester.widget<Text>(find.text(label));
|
||
expect(text.style?.fontSize, contentListTitleTextStyle.fontSize);
|
||
expect(text.style?.height, contentListTitleTextStyle.height);
|
||
expect(
|
||
text.style?.color,
|
||
Theme.of(
|
||
tester.element(find.text(label)),
|
||
).colorScheme.onSurface.withValues(alpha: 0.8),
|
||
);
|
||
}
|
||
final channelIcon = tester.widget<Icon>(
|
||
find.byKey(const ValueKey('channel-icon-1')),
|
||
);
|
||
expect(
|
||
channelIcon.color,
|
||
Theme.of(
|
||
tester.element(find.byKey(const ValueKey('channel-icon-1'))),
|
||
).colorScheme.onSurface.withValues(alpha: 0.8),
|
||
);
|
||
final sectionTitle = tester.widget<Text>(find.text('Channels'));
|
||
expect(sectionTitle.style?.fontSize, contentListTitleTextStyle.fontSize);
|
||
expect(sectionTitle.style?.fontWeight, FontWeight.w600);
|
||
});
|
||
|
||
testWidgets('keys DM tile fallback avatars to the non-self counterpart', (
|
||
tester,
|
||
) async {
|
||
// Valid fixture keys whose npub encodings were verified against the
|
||
// NIP-19 codec independently of the code under test. The current user
|
||
// (aabb, from the fake profile) is listed FIRST — member order does
|
||
// not guarantee the counterpart is first — so an avatar that keys off
|
||
// the first participant would identify the current user while the
|
||
// label beside it identifies the counterpart.
|
||
const b0b =
|
||
'b0b0000000000000000000000000000000000000000000000000000000000000';
|
||
final selfFirstUnnamedDm = Channel(
|
||
id: 'dm-self-first-unnamed',
|
||
name: 'DM',
|
||
channelType: 'dm',
|
||
visibility: 'open',
|
||
description: 'Direct message',
|
||
createdBy: 'aabb',
|
||
createdAt: DateTime(2025),
|
||
memberCount: 2,
|
||
participants: ['Test', shortPubkey(b0b)],
|
||
participantPubkeys: const ['aabb', b0b],
|
||
isMember: true,
|
||
);
|
||
final selfFirstNamedDm = Channel(
|
||
id: 'dm-self-first-named',
|
||
name: 'DM',
|
||
channelType: 'dm',
|
||
visibility: 'open',
|
||
description: 'Direct message',
|
||
createdBy: 'aabb',
|
||
createdAt: DateTime(2025),
|
||
memberCount: 2,
|
||
participants: const ['Test', 'Dana'],
|
||
participantPubkeys: const ['aabb', b0b],
|
||
isMember: true,
|
||
);
|
||
await tester.pumpWidget(
|
||
buildTestable(
|
||
overrides: [
|
||
channelsProvider.overrideWith(
|
||
() => _FakeNotifier([selfFirstUnnamedDm, selfFirstNamedDm]),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
await tester.pumpAndSettle();
|
||
|
||
// Label and avatar agree on the counterpart's key: the compact npub
|
||
// names the unnamed counterpart, and the avatar initial is keyed to
|
||
// that same hex key — never the current user's `A`.
|
||
expect(find.text(shortPubkey(b0b)), findsOneWidget);
|
||
expect(_dmTileAvatarInitial(tester, shortPubkey(b0b)), 'B');
|
||
// A named counterpart listed second keeps its authored initial too —
|
||
// `D`, not its key's `B`, proves the name is the initial's source.
|
||
expect(_dmTileAvatarInitial(tester, 'Dana'), 'D');
|
||
});
|
||
|
||
testWidgets('keys the self-DM tile avatar to the current user key', (
|
||
tester,
|
||
) async {
|
||
// A self-DM lists only the current user, so the tile label falls back
|
||
// to the sole participant — the compact npub of the current user's
|
||
// own key.
|
||
const b0b =
|
||
'b0b0000000000000000000000000000000000000000000000000000000000000';
|
||
final selfDm = Channel(
|
||
id: 'dm-self',
|
||
name: 'DM',
|
||
channelType: 'dm',
|
||
visibility: 'open',
|
||
description: 'Direct message',
|
||
createdBy: b0b,
|
||
createdAt: DateTime(2025),
|
||
memberCount: 1,
|
||
participants: [shortPubkey(b0b)],
|
||
participantPubkeys: const [b0b],
|
||
isMember: true,
|
||
);
|
||
await tester.pumpWidget(
|
||
buildTestable(
|
||
// The tile reads the current user's pubkey from profileProvider, so
|
||
// the self-DM needs the fake profile keyed to the same participant.
|
||
profile: _FakeProfileNotifier(pubkey: b0b),
|
||
overrides: [
|
||
channelsProvider.overrideWith(() => _FakeNotifier([selfDm])),
|
||
],
|
||
),
|
||
);
|
||
await tester.pumpAndSettle();
|
||
|
||
// The compact-npub label and its avatar identify the same key: the
|
||
// hex-key initial `B`, never the `N` the npub label starts with.
|
||
expect(find.text(shortPubkey(b0b)), findsOneWidget);
|
||
expect(_dmTileAvatarInitial(tester, shortPubkey(b0b)), 'B');
|
||
});
|
||
|
||
testWidgets('sizes the community header for accessible text', (tester) async {
|
||
await tester.pumpWidget(
|
||
buildTestable(
|
||
textScaler: const TextScaler.linear(2),
|
||
overrides: [
|
||
channelsProvider.overrideWith(() => _FakeNotifier(testChannels)),
|
||
],
|
||
),
|
||
);
|
||
await tester.pumpAndSettle();
|
||
|
||
final appBar = tester.widget<FrostedAppBar>(
|
||
find.byType(FrostedAppBar).last,
|
||
);
|
||
final titleStyle = appBar.titleStyle!;
|
||
expect(titleStyle.fontSize, 22);
|
||
expect(
|
||
tester
|
||
.getSize(
|
||
find.descendant(
|
||
of: find.byType(FrostedAppBar).last,
|
||
matching: find.byType(ClipRect),
|
||
),
|
||
)
|
||
.height,
|
||
closeTo(
|
||
frostedAppBarHeight(
|
||
tester.element(find.byType(FrostedAppBar).last),
|
||
titleStyle: titleStyle,
|
||
bottomHeight: appBar.bottomHeight,
|
||
) -
|
||
1,
|
||
0.01,
|
||
),
|
||
);
|
||
expect(tester.takeException(), isNull);
|
||
});
|
||
|
||
testWidgets('interrupts a ballistic scroll from a transparent list gap', (
|
||
tester,
|
||
) async {
|
||
tester.view.physicalSize = const Size(320, 480);
|
||
tester.view.devicePixelRatio = 1;
|
||
addTearDown(tester.view.reset);
|
||
final channels = List.generate(
|
||
40,
|
||
(index) => Channel(
|
||
id: 'channel-$index',
|
||
name: 'channel-$index',
|
||
channelType: 'stream',
|
||
visibility: 'open',
|
||
description: 'Channel $index',
|
||
createdBy: 'abc',
|
||
createdAt: DateTime(2025),
|
||
memberCount: 10,
|
||
isMember: true,
|
||
),
|
||
);
|
||
await tester.pumpWidget(
|
||
buildTestable(
|
||
overrides: [
|
||
channelsProvider.overrideWith(() => _FakeNotifier(channels)),
|
||
],
|
||
),
|
||
);
|
||
await tester.pumpAndSettle();
|
||
|
||
final scrollView = find.byType(CustomScrollView);
|
||
final scrollable = tester.state<ScrollableState>(
|
||
find.descendant(of: scrollView, matching: find.byType(Scrollable)).first,
|
||
);
|
||
await tester.fling(scrollView, const Offset(0, -300), 2400);
|
||
await tester.pump(const Duration(milliseconds: 32));
|
||
final ballisticOffset = scrollable.position.pixels;
|
||
await tester.pump(const Duration(milliseconds: 32));
|
||
expect(scrollable.position.pixels, greaterThan(ballisticOffset));
|
||
|
||
// x=1 is inside the scroll viewport but outside the padded section rows.
|
||
// A drag beginning here must still enter the scrollable's gesture arena.
|
||
final interruptingDrag = await tester.startGesture(const Offset(1, 300));
|
||
await interruptingDrag.moveBy(const Offset(0, 80));
|
||
await tester.pump();
|
||
|
||
expect(scrollable.position.pixels, lessThan(ballisticOffset));
|
||
await interruptingDrag.up();
|
||
});
|
||
|
||
testWidgets('keeps the last channel above the floating tab bar', (
|
||
tester,
|
||
) async {
|
||
const footerClearance = 102.0;
|
||
await tester.pumpWidget(
|
||
buildTestable(
|
||
bottomPadding: footerClearance,
|
||
overrides: [
|
||
channelsProvider.overrideWith(() => _FakeNotifier(testChannels)),
|
||
],
|
||
),
|
||
);
|
||
await tester.pumpAndSettle();
|
||
|
||
final padding = tester.widget<SliverPadding>(
|
||
find.descendant(
|
||
of: find.byType(CustomScrollView),
|
||
matching: find.byType(SliverPadding),
|
||
),
|
||
);
|
||
expect((padding.padding as EdgeInsets).bottom, footerClearance);
|
||
});
|
||
|
||
testWidgets('balances an expanded section around its following divider', (
|
||
tester,
|
||
) async {
|
||
await tester.pumpWidget(
|
||
buildTestable(
|
||
overrides: [
|
||
channelsProvider.overrideWith(() => _FakeNotifier(testChannels)),
|
||
],
|
||
),
|
||
);
|
||
await tester.pumpAndSettle();
|
||
|
||
final lastChannel = tester.getRect(find.text('general'));
|
||
final divider = tester.getRect(find.byType(Divider).last);
|
||
final nextSectionHeader = tester.getRect(find.text('DMs'));
|
||
|
||
expect(
|
||
divider.top - lastChannel.bottom,
|
||
closeTo(nextSectionHeader.top - divider.bottom, 0.01),
|
||
);
|
||
});
|
||
|
||
testWidgets('keeps the Buzz background fixed behind the channels list', (
|
||
tester,
|
||
) async {
|
||
await tester.pumpWidget(
|
||
buildTestable(
|
||
topSectionGradient: const LinearGradient(
|
||
begin: Alignment.topCenter,
|
||
end: Alignment.bottomCenter,
|
||
colors: [Colors.yellow, Colors.blue],
|
||
),
|
||
overrides: [
|
||
channelsProvider.overrideWith(() => _FakeNotifier(testChannels)),
|
||
],
|
||
),
|
||
);
|
||
await tester.pumpAndSettle();
|
||
|
||
expect(
|
||
find.byKey(const ValueKey('frosted-scaffold-pinned-gradient')),
|
||
findsOneWidget,
|
||
);
|
||
expect(find.byType(DecoratedSliver), findsNothing);
|
||
final gradientBackground = tester.widget<DecoratedBox>(
|
||
find.byKey(const ValueKey('frosted-scaffold-pinned-gradient')),
|
||
);
|
||
final gradient =
|
||
(gradientBackground.decoration as BoxDecoration).gradient
|
||
as LinearGradient;
|
||
expect(gradient.end, Alignment.bottomCenter);
|
||
|
||
final appBar = tester.widget<FrostedAppBar>(
|
||
find.byType(FrostedAppBar).last,
|
||
);
|
||
expect(appBar.frosted, isFalse);
|
||
expect(appBar.frostedSurfaceOpacity, 0);
|
||
expect(appBar.frostedBlurSigma, 0);
|
||
expect(appBar.showBottomDivider, isFalse);
|
||
expect(appBar.bottomHeight, Grid.xxs);
|
||
});
|
||
|
||
testWidgets('builds Home header frost progressively while scrolling', (
|
||
tester,
|
||
) async {
|
||
tester.view.physicalSize = const Size(320, 160);
|
||
tester.view.devicePixelRatio = 1;
|
||
addTearDown(tester.view.reset);
|
||
await tester.pumpWidget(
|
||
buildTestable(
|
||
topSectionGradient: const LinearGradient(
|
||
colors: [Colors.yellow, Colors.blue],
|
||
),
|
||
overrides: [
|
||
channelsProvider.overrideWith(() => _FakeNotifier(testChannels)),
|
||
],
|
||
),
|
||
);
|
||
await tester.pumpAndSettle();
|
||
|
||
final scrollable = tester.state<ScrollableState>(
|
||
find
|
||
.descendant(
|
||
of: find.byType(CustomScrollView),
|
||
matching: find.byType(Scrollable),
|
||
)
|
||
.first,
|
||
);
|
||
expect(scrollable.position.maxScrollExtent, greaterThanOrEqualTo(Grid.xxl));
|
||
|
||
scrollable.position.jumpTo(Grid.xl / 2);
|
||
await tester.pump();
|
||
var appBar = tester.widget<FrostedAppBar>(find.byType(FrostedAppBar).last);
|
||
expect(appBar.frosted, isTrue);
|
||
expect(appBar.frostedSurfaceOpacity, 0);
|
||
expect(appBar.frostedBlurSigma, closeTo(8.67, 0.001));
|
||
|
||
scrollable.position.jumpTo(Grid.xxl);
|
||
await tester.pump();
|
||
appBar = tester.widget<FrostedAppBar>(find.byType(FrostedAppBar).last);
|
||
expect(appBar.frostedSurfaceOpacity, 0);
|
||
expect(appBar.frostedBlurSigma, 23.12);
|
||
});
|
||
|
||
testWidgets('scrolls Home to the top when its tab is selected again', (
|
||
tester,
|
||
) async {
|
||
tester.view.physicalSize = const Size(320, 160);
|
||
tester.view.devicePixelRatio = 1;
|
||
addTearDown(tester.view.reset);
|
||
final tabReselection = ValueNotifier(0);
|
||
addTearDown(tabReselection.dispose);
|
||
await tester.pumpWidget(
|
||
buildTestable(
|
||
tabReselection: tabReselection,
|
||
overrides: [
|
||
channelsProvider.overrideWith(() => _FakeNotifier(testChannels)),
|
||
],
|
||
),
|
||
);
|
||
await tester.pumpAndSettle();
|
||
|
||
final scrollable = tester.state<ScrollableState>(
|
||
find
|
||
.descendant(
|
||
of: find.byType(CustomScrollView),
|
||
matching: find.byType(Scrollable),
|
||
)
|
||
.first,
|
||
);
|
||
scrollable.position.jumpTo(scrollable.position.maxScrollExtent);
|
||
tabReselection.value++;
|
||
await tester.pump();
|
||
await tester.pump(const Duration(milliseconds: 130));
|
||
|
||
expect(
|
||
scrollable.position.pixels,
|
||
lessThan(scrollable.position.maxScrollExtent),
|
||
);
|
||
await tester.pumpAndSettle();
|
||
expect(scrollable.position.pixels, scrollable.position.minScrollExtent);
|
||
});
|
||
|
||
testWidgets('truncates long custom section names beside the menu', (
|
||
tester,
|
||
) async {
|
||
tester.view.physicalSize = const Size(320, 800);
|
||
tester.view.devicePixelRatio = 1;
|
||
addTearDown(tester.view.reset);
|
||
const sectionName = 'A deliberately long custom section name for testing';
|
||
await tester.pumpWidget(
|
||
buildTestable(
|
||
overrides: [
|
||
channelsProvider.overrideWith(() => _FakeNotifier(testChannels)),
|
||
channelSectionsProvider.overrideWith(
|
||
() => _FakeChannelSectionsNotifier(
|
||
const ChannelSectionStore(
|
||
sections: [
|
||
ChannelSection(id: 'section-1', name: sectionName, order: 0),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
await tester.pumpAndSettle();
|
||
|
||
final label = tester.widget<Text>(find.text(sectionName));
|
||
expect(label.maxLines, 1);
|
||
expect(label.overflow, TextOverflow.ellipsis);
|
||
expect(tester.takeException(), isNull);
|
||
});
|
||
|
||
testWidgets('section menu matches desktop labels, icons, and inset', (
|
||
tester,
|
||
) async {
|
||
await tester.pumpWidget(
|
||
buildTestable(
|
||
overrides: [
|
||
channelsProvider.overrideWith(() => _FakeNotifier(testChannels)),
|
||
channelSectionsProvider.overrideWith(
|
||
() => _FakeChannelSectionsNotifier(
|
||
const ChannelSectionStore(
|
||
sections: [
|
||
ChannelSection(id: 'section-1', name: 'Design', order: 0),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
await tester.pumpAndSettle();
|
||
|
||
await tester.tap(find.byKey(const Key('section-menu-section-1')));
|
||
await tester.pumpAndSettle();
|
||
|
||
final popover = find.byKey(const Key('section-popover-section-1'));
|
||
expect(popover, findsOneWidget);
|
||
for (final label in [
|
||
'Rename section',
|
||
'Move up',
|
||
'Move down',
|
||
'Delete section',
|
||
]) {
|
||
expect(
|
||
find.descendant(of: popover, matching: find.text(label)),
|
||
findsOne,
|
||
);
|
||
}
|
||
for (final icon in [
|
||
LucideIcons.pencil,
|
||
LucideIcons.arrowUp,
|
||
LucideIcons.arrowDown,
|
||
LucideIcons.trash2,
|
||
]) {
|
||
expect(
|
||
find.descendant(of: popover, matching: find.byIcon(icon)),
|
||
findsOne,
|
||
);
|
||
}
|
||
|
||
final actionMenuItems = tester
|
||
.widgetList<PopupMenuItem<String>>(
|
||
find.descendant(
|
||
of: popover,
|
||
matching: find.byWidgetPredicate(
|
||
(widget) => widget is PopupMenuItem<String>,
|
||
),
|
||
),
|
||
)
|
||
.where(
|
||
(item) => const {
|
||
'rename',
|
||
'move_up',
|
||
'move_down',
|
||
'delete',
|
||
}.contains(item.value),
|
||
);
|
||
expect(actionMenuItems, hasLength(4));
|
||
for (final item in actionMenuItems) {
|
||
expect(
|
||
item.padding,
|
||
const EdgeInsets.fromLTRB(Grid.xs, 0, Grid.twelve, 0),
|
||
);
|
||
}
|
||
|
||
final error = Theme.of(tester.element(popover)).colorScheme.error;
|
||
final deleteText = tester.widget<Text>(find.text('Delete section'));
|
||
final deleteIcon = tester.widget<Icon>(
|
||
find.descendant(of: popover, matching: find.byIcon(LucideIcons.trash2)),
|
||
);
|
||
expect(deleteText.style?.color, error);
|
||
expect(deleteIcon.color, error);
|
||
});
|
||
|
||
testWidgets('aligns the top, section, row, and skeleton label columns', (
|
||
tester,
|
||
) async {
|
||
final relaySession = _ReconnectingRelaySession();
|
||
await tester.pumpWidget(
|
||
buildTestable(
|
||
overrides: [
|
||
channelsProvider.overrideWith(() => _FakeNotifier(testChannels)),
|
||
relaySessionProvider.overrideWith(() => relaySession),
|
||
],
|
||
),
|
||
);
|
||
relaySession.connect();
|
||
await tester.pumpAndSettle();
|
||
|
||
final topLabelX = tester.getTopLeft(find.text('Community')).dx;
|
||
final sectionLabelX = tester.getTopLeft(find.text('Channels')).dx;
|
||
final rowLabelX = tester.getTopLeft(find.text('general')).dx;
|
||
// The community title shares the leading row with its avatar. Channel
|
||
// labels stay aligned below it.
|
||
expect(topLabelX, Grid.twelve + 40 + Grid.xxs);
|
||
expect(sectionLabelX, rowLabelX);
|
||
|
||
relaySession.setReconnecting();
|
||
await tester.pump();
|
||
await tester.pump(const Duration(seconds: 2));
|
||
await tester.pump();
|
||
|
||
final skeletonSectionLabelX = tester
|
||
.getTopLeft(
|
||
find.byKey(const Key('channels-skeleton-section-label')).first,
|
||
)
|
||
.dx;
|
||
final skeletonRowLabelX = tester
|
||
.getTopLeft(
|
||
find.byKey(const Key('channels-skeleton-row-label-0')).first,
|
||
)
|
||
.dx;
|
||
expect(skeletonSectionLabelX, skeletonRowLabelX);
|
||
expect(skeletonSectionLabelX, sectionLabelX);
|
||
});
|
||
|
||
testWidgets('centers the smaller profile avatar beside the community', (
|
||
tester,
|
||
) async {
|
||
await tester.pumpWidget(
|
||
buildTestable(
|
||
overrides: [
|
||
channelsProvider.overrideWith(() => _FakeNotifier(testChannels)),
|
||
],
|
||
),
|
||
);
|
||
await tester.pumpAndSettle();
|
||
|
||
final appBar = find.byType(FrostedAppBar).last;
|
||
final communityAvatar = find.descendant(
|
||
of: appBar,
|
||
matching: find.byType(AvatarImage),
|
||
);
|
||
final profileAvatar = find.descendant(
|
||
of: appBar,
|
||
matching: find.byType(MaskedAvatarBadge),
|
||
);
|
||
|
||
expect(tester.getSize(communityAvatar), const Size.square(40));
|
||
expect(tester.getSize(profileAvatar), const Size.square(36));
|
||
expect(
|
||
tester.widget<MaskedAvatarBadge>(profileAvatar).badge,
|
||
isNull,
|
||
reason: 'The current user does not need an online dot on Home.',
|
||
);
|
||
final communityRect = tester.getRect(communityAvatar);
|
||
final profileRect = tester.getRect(profileAvatar);
|
||
expect(profileRect.center.dy, communityRect.center.dy);
|
||
});
|
||
|
||
testWidgets('reveals channel content from same-slot reconnect skeletons', (
|
||
tester,
|
||
) async {
|
||
final relaySession = _ReconnectingRelaySession();
|
||
await tester.pumpWidget(
|
||
buildTestable(
|
||
overrides: [
|
||
channelsProvider.overrideWith(() => _FakeNotifier(testChannels)),
|
||
relaySessionProvider.overrideWith(() => relaySession),
|
||
],
|
||
),
|
||
);
|
||
await tester.pump();
|
||
await tester.pump(const Duration(seconds: 2));
|
||
await tester.pump();
|
||
|
||
final skeleton = find.byKey(const Key('channels-connection-skeleton'));
|
||
expect(skeleton, findsOneWidget);
|
||
expect(
|
||
find.descendant(of: skeleton, matching: find.byType(SkeletonBar)),
|
||
findsWidgets,
|
||
);
|
||
expect(
|
||
find.descendant(
|
||
of: skeleton,
|
||
matching: find.byType(CircularProgressIndicator),
|
||
),
|
||
findsNothing,
|
||
);
|
||
expect(
|
||
tester
|
||
.widget<Opacity>(find.byKey(const Key('skeleton-reveal-placeholder')))
|
||
.opacity,
|
||
1,
|
||
);
|
||
expect(
|
||
tester
|
||
.widget<Opacity>(find.byKey(const Key('skeleton-reveal-content')))
|
||
.opacity,
|
||
0,
|
||
);
|
||
|
||
relaySession.connect();
|
||
await tester.pump();
|
||
await tester.pump();
|
||
expect(
|
||
tester.widget<SkeletonReveal>(find.byType(SkeletonReveal)).loading,
|
||
isFalse,
|
||
);
|
||
await tester.pump(const Duration(milliseconds: 200));
|
||
|
||
expect(
|
||
tester
|
||
.widget<Opacity>(find.byKey(const Key('skeleton-reveal-placeholder')))
|
||
.opacity,
|
||
closeTo(0.5, 0.01),
|
||
);
|
||
expect(
|
||
tester
|
||
.widget<Opacity>(find.byKey(const Key('skeleton-reveal-content')))
|
||
.opacity,
|
||
closeTo(0.5, 0.01),
|
||
);
|
||
|
||
await tester.pump(const Duration(milliseconds: 200));
|
||
expect(find.text('general'), findsOneWidget);
|
||
});
|
||
|
||
testWidgets('announces neutral loading outside connection transitions', (
|
||
tester,
|
||
) async {
|
||
final relaySession = _ReconnectingRelaySession(
|
||
initialStatus: SessionStatus.connected,
|
||
);
|
||
await tester.pumpWidget(
|
||
buildTestable(
|
||
overrides: [
|
||
channelsProvider.overrideWith(() => _LoadingNotifier()),
|
||
relaySessionProvider.overrideWith(() => relaySession),
|
||
],
|
||
),
|
||
);
|
||
await tester.pump();
|
||
|
||
expect(
|
||
tester
|
||
.widget<Semantics>(
|
||
find.byKey(const Key('channels-connection-skeleton')),
|
||
)
|
||
.properties
|
||
.label,
|
||
'Loading',
|
||
);
|
||
});
|
||
|
||
testWidgets('opens the settings page supplied by the app layer', (
|
||
tester,
|
||
) async {
|
||
await tester.pumpWidget(
|
||
buildTestable(
|
||
overrides: [
|
||
channelsProvider.overrideWith(() => _FakeNotifier(testChannels)),
|
||
],
|
||
),
|
||
);
|
||
await tester.pumpAndSettle();
|
||
|
||
expect(find.byType(Hero), findsNothing);
|
||
await tester.tap(find.byType(ProfileAvatar));
|
||
await tester.pumpAndSettle();
|
||
|
||
expect(find.text('Injected settings'), findsOneWidget);
|
||
final route = ModalRoute.of(tester.element(find.text('Injected settings')));
|
||
expect(route, isNot(isA<MaterialPageRoute<void>>()));
|
||
expect(route?.opaque, isFalse);
|
||
expect(route?.allowSnapshotting, isFalse);
|
||
expect(route?.transitionDuration, const Duration(milliseconds: 150));
|
||
expect(route?.reverseTransitionDuration, const Duration(milliseconds: 150));
|
||
});
|
||
|
||
testWidgets('reports monotonic Settings route progress', (tester) async {
|
||
final progress = <double>[];
|
||
await tester.pumpWidget(
|
||
buildTestable(
|
||
overrides: [
|
||
channelsProvider.overrideWith(() => _FakeNotifier(testChannels)),
|
||
],
|
||
onSettingsTransitionProgress: progress.add,
|
||
),
|
||
);
|
||
await tester.pumpAndSettle();
|
||
|
||
await tester.tap(find.byType(ProfileAvatar));
|
||
await tester.pump();
|
||
expect(progress, [0]);
|
||
await tester.pump(const Duration(milliseconds: 16));
|
||
expect(progress.last, inExclusiveRange(0, 1));
|
||
await tester.pump(const Duration(milliseconds: 95));
|
||
expect(progress.last, inExclusiveRange(0, 1));
|
||
await tester.pumpAndSettle();
|
||
expect(progress.last, 1);
|
||
for (var index = 1; index < progress.length; index++) {
|
||
expect(progress[index], greaterThanOrEqualTo(progress[index - 1]));
|
||
}
|
||
|
||
Navigator.of(tester.element(find.text('Injected settings'))).pop();
|
||
await tester.pump();
|
||
final reverseStart = progress.length;
|
||
await tester.pump(const Duration(milliseconds: 95));
|
||
expect(progress.last, inExclusiveRange(0, 1));
|
||
await tester.pumpAndSettle();
|
||
expect(progress.last, 0);
|
||
for (var index = reverseStart + 1; index < progress.length; index++) {
|
||
expect(progress[index], lessThanOrEqualTo(progress[index - 1]));
|
||
}
|
||
});
|
||
|
||
testWidgets('scales and fully fades Settings into view', (tester) async {
|
||
await tester.pumpWidget(
|
||
buildTestable(
|
||
overrides: [
|
||
channelsProvider.overrideWith(() => _FakeNotifier(testChannels)),
|
||
],
|
||
),
|
||
);
|
||
await tester.pumpAndSettle();
|
||
|
||
await tester.tap(find.byType(ProfileAvatar));
|
||
await tester.pump();
|
||
|
||
final transition = find.byKey(
|
||
const ValueKey('settings-transition-opacity'),
|
||
skipOffstage: false,
|
||
);
|
||
final scaleTransition = find.byKey(
|
||
const ValueKey('settings-transition-scale'),
|
||
skipOffstage: false,
|
||
);
|
||
expect(transition, findsOneWidget);
|
||
expect(scaleTransition, findsOneWidget);
|
||
expect(
|
||
find.descendant(
|
||
of: transition,
|
||
matching: find.byKey(
|
||
const ValueKey('settings-transition-layer'),
|
||
skipOffstage: false,
|
||
),
|
||
),
|
||
findsOneWidget,
|
||
);
|
||
expect(tester.widget<FadeTransition>(transition).opacity.value, 0);
|
||
|
||
await tester.pump(const Duration(milliseconds: 95));
|
||
final forwardOpacity = tester
|
||
.widget<FadeTransition>(transition)
|
||
.opacity
|
||
.value;
|
||
final forwardScale = tester
|
||
.widget<ScaleTransition>(scaleTransition)
|
||
.scale
|
||
.value;
|
||
final forwardScaleProgress = (1.04 - forwardScale) / 0.04;
|
||
expect(
|
||
forwardOpacity,
|
||
closeTo(Curves.easeOutQuad.transform(95 / 150), 0.02),
|
||
);
|
||
expect(forwardScaleProgress, closeTo(forwardOpacity, 0.001));
|
||
await tester.pumpAndSettle();
|
||
expect(tester.widget<FadeTransition>(transition).opacity.value, 1);
|
||
|
||
Navigator.of(tester.element(find.text('Injected settings'))).pop();
|
||
await tester.pump();
|
||
await tester.pump(const Duration(milliseconds: 95));
|
||
final reverseOpacity = tester
|
||
.widget<FadeTransition>(transition)
|
||
.opacity
|
||
.value;
|
||
final reverseScale = tester
|
||
.widget<ScaleTransition>(scaleTransition)
|
||
.scale
|
||
.value;
|
||
final reverseScaleProgress = (1.04 - reverseScale) / 0.04;
|
||
expect(
|
||
reverseOpacity,
|
||
inExclusiveRange(0, 1),
|
||
reason: 'The complete Settings layer still fades out on exit.',
|
||
);
|
||
expect(reverseScaleProgress, closeTo(reverseOpacity, 0.001));
|
||
});
|
||
|
||
testWidgets('gives feedback for the profile and community controls', (
|
||
tester,
|
||
) async {
|
||
final hapticCalls = <MethodCall>[];
|
||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||
.setMockMethodCallHandler(SystemChannels.platform, (call) async {
|
||
if (call.method == 'HapticFeedback.vibrate') hapticCalls.add(call);
|
||
return null;
|
||
});
|
||
addTearDown(
|
||
() => TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||
.setMockMethodCallHandler(SystemChannels.platform, null),
|
||
);
|
||
await tester.pumpWidget(
|
||
buildTestable(
|
||
overrides: [
|
||
channelsProvider.overrideWith(() => _FakeNotifier(testChannels)),
|
||
],
|
||
),
|
||
);
|
||
await tester.pumpAndSettle();
|
||
|
||
await tester.tap(find.byType(ProfileAvatar));
|
||
await tester.pumpAndSettle();
|
||
expect(hapticCalls.single.arguments, 'HapticFeedbackType.lightImpact');
|
||
|
||
Navigator.of(tester.element(find.text('Injected settings'))).pop();
|
||
await tester.pumpAndSettle();
|
||
final communityAvatar = find.descendant(
|
||
of: find.byType(FrostedAppBar).last,
|
||
matching: find.byType(AvatarImage),
|
||
);
|
||
await tester.tap(communityAvatar);
|
||
await tester.pump();
|
||
expect(hapticCalls.last.arguments, 'HapticFeedbackType.selectionClick');
|
||
});
|
||
|
||
testWidgets('community switcher separates selection from edit removal', (
|
||
tester,
|
||
) async {
|
||
tester.view.physicalSize = const Size(390, 844);
|
||
tester.view.devicePixelRatio = 1;
|
||
addTearDown(tester.view.resetPhysicalSize);
|
||
addTearDown(tester.view.resetDevicePixelRatio);
|
||
|
||
final communities = [
|
||
Community(
|
||
id: 'alpha',
|
||
name: 'Alpha',
|
||
relayUrl: 'wss://alpha.example.com',
|
||
addedAt: DateTime(2025),
|
||
),
|
||
Community(
|
||
id: 'bravo',
|
||
name: 'Bravo',
|
||
relayUrl: 'wss://bravo.example.com',
|
||
addedAt: DateTime(2025),
|
||
),
|
||
];
|
||
final communityNotifier = _FakeCommunityListNotifier(communities);
|
||
|
||
await tester.pumpWidget(
|
||
buildTestable(
|
||
communityIcons: const {
|
||
'wss://alpha.example.com':
|
||
'data:image/png;base64,'
|
||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
|
||
},
|
||
overrides: [
|
||
channelsProvider.overrideWith(() => _FakeNotifier(testChannels)),
|
||
communityListProvider.overrideWith(() => communityNotifier),
|
||
activeCommunityProvider.overrideWith(
|
||
(ref) async => communities.first,
|
||
),
|
||
],
|
||
),
|
||
);
|
||
await tester.pumpAndSettle();
|
||
|
||
await tester.tap(find.text('Alpha'));
|
||
await tester.pumpAndSettle();
|
||
|
||
expect(find.text('Switch Community'), findsOneWidget);
|
||
final options = find.byKey(const Key('community-switcher-options'));
|
||
expect(options, findsOneWidget);
|
||
final editButton = find.byKey(const Key('community-switcher-edit'));
|
||
expect(tester.getSize(editButton), const Size(56, 44));
|
||
expect(find.byTooltip('Close sheet'), findsNothing);
|
||
expect(
|
||
tester.getCenter(editButton).dx,
|
||
greaterThan(tester.getCenter(find.text('Switch Community')).dx),
|
||
);
|
||
expect(find.text('alpha.example.com'), findsOneWidget);
|
||
expect(find.text('bravo.example.com'), findsOneWidget);
|
||
expect(find.text('Rename'), findsNothing);
|
||
expect(
|
||
find.descendant(
|
||
of: options,
|
||
matching: find.byIcon(LucideIcons.ellipsisVertical),
|
||
),
|
||
findsNothing,
|
||
);
|
||
expect(find.text('Edit'), findsOneWidget);
|
||
expect(find.byIcon(LucideIcons.trash2), findsNothing);
|
||
expect(
|
||
tester.getSize(find.byKey(const Key('community-switcher-avatar-alpha'))),
|
||
const Size.square(36),
|
||
);
|
||
final alphaAvatar = tester.widget<AvatarImage>(
|
||
find.descendant(
|
||
of: find.byKey(const Key('community-switcher-avatar-alpha')),
|
||
matching: find.byType(AvatarImage),
|
||
),
|
||
);
|
||
expect(alphaAvatar.imageUrl, startsWith('data:image/png;base64,'));
|
||
final activeSelection = find.byKey(
|
||
const Key('community-switcher-selection-alpha'),
|
||
);
|
||
final inactiveSelection = find.byKey(
|
||
const Key('community-switcher-selection-bravo'),
|
||
);
|
||
expect(
|
||
tester.getCenter(editButton).dx,
|
||
closeTo(tester.getCenter(activeSelection).dx, 0.01),
|
||
);
|
||
expect(tester.getSize(activeSelection), const Size.square(40));
|
||
expect(
|
||
tester.getSize(find.byKey(const Key('community-switcher-circle-alpha'))),
|
||
const Size.square(24),
|
||
);
|
||
expect(
|
||
find.descendant(
|
||
of: activeSelection,
|
||
matching: find.byIcon(LucideIcons.check),
|
||
),
|
||
findsOneWidget,
|
||
);
|
||
expect(
|
||
find.descendant(
|
||
of: inactiveSelection,
|
||
matching: find.byIcon(LucideIcons.check),
|
||
),
|
||
findsNothing,
|
||
);
|
||
expect(
|
||
find.descendant(of: options, matching: find.byType(Divider)),
|
||
findsNWidgets(2),
|
||
);
|
||
|
||
await tester.tap(find.text('Edit'));
|
||
await tester.pump();
|
||
|
||
final activeAction = find.byKey(
|
||
const Key('community-switcher-action-alpha'),
|
||
);
|
||
final actionSwitcher = tester.widget<AnimatedSwitcher>(
|
||
find.descendant(
|
||
of: activeAction,
|
||
matching: find.byType(AnimatedSwitcher),
|
||
),
|
||
);
|
||
expect(actionSwitcher.duration, const Duration(milliseconds: 250));
|
||
expect(activeSelection, findsOneWidget);
|
||
expect(
|
||
find.byKey(const Key('community-switcher-remove-alpha')),
|
||
findsOneWidget,
|
||
);
|
||
|
||
await tester.pump(const Duration(milliseconds: 125));
|
||
|
||
final transitioningOpacities = tester
|
||
.widgetList<Opacity>(
|
||
find.descendant(of: activeAction, matching: find.byType(Opacity)),
|
||
)
|
||
.map((opacity) => opacity.opacity);
|
||
expect(
|
||
transitioningOpacities.any((opacity) => opacity > 0 && opacity < 1),
|
||
isTrue,
|
||
);
|
||
expect(
|
||
find.descendant(of: activeAction, matching: find.byType(ImageFiltered)),
|
||
findsWidgets,
|
||
);
|
||
|
||
await tester.pumpAndSettle();
|
||
|
||
expect(find.text('Done'), findsOneWidget);
|
||
expect(find.byIcon(LucideIcons.trash2), findsNWidgets(2));
|
||
expect(activeSelection, findsNothing);
|
||
expect(inactiveSelection, findsNothing);
|
||
|
||
await tester.tap(find.byTooltip('Remove Bravo'));
|
||
await tester.pumpAndSettle();
|
||
|
||
expect(find.text('Remove community?'), findsOneWidget);
|
||
expect(
|
||
find.text(
|
||
'Are you sure you want to remove “Bravo”? '
|
||
'You can pair with it again later.',
|
||
),
|
||
findsOneWidget,
|
||
);
|
||
|
||
await tester.tap(find.widgetWithText(FilledButton, 'Remove'));
|
||
await tester.pumpAndSettle();
|
||
|
||
expect(communityNotifier.removedIds, ['bravo']);
|
||
expect(find.text('Switch Community'), findsOneWidget);
|
||
expect(find.text('Bravo'), findsNothing);
|
||
expect(find.text('Done'), findsOneWidget);
|
||
|
||
await tester.tap(find.text('Done'));
|
||
await tester.pumpAndSettle();
|
||
|
||
expect(find.text('Edit'), findsOneWidget);
|
||
expect(
|
||
find.byKey(const Key('community-switcher-selection-alpha')),
|
||
findsOneWidget,
|
||
);
|
||
});
|
||
|
||
testWidgets('opening the community switcher refreshes visible icons', (
|
||
tester,
|
||
) async {
|
||
final community = Community(
|
||
id: 'alpha',
|
||
name: 'Alpha',
|
||
relayUrl: 'wss://alpha.example.com',
|
||
addedAt: DateTime(2025),
|
||
);
|
||
var iconLoads = 0;
|
||
|
||
await tester.pumpWidget(
|
||
buildTestable(
|
||
onCommunityIconLoad: (_) => iconLoads++,
|
||
overrides: [
|
||
channelsProvider.overrideWith(() => _FakeNotifier(testChannels)),
|
||
communityListProvider.overrideWith(
|
||
() => _FakeCommunityListNotifier([community]),
|
||
),
|
||
activeCommunityProvider.overrideWith((ref) async => community),
|
||
],
|
||
),
|
||
);
|
||
await tester.pumpAndSettle();
|
||
final initialIconLoads = iconLoads;
|
||
|
||
await tester.tap(find.text('Alpha'));
|
||
await tester.pumpAndSettle();
|
||
|
||
expect(iconLoads, greaterThan(initialIconLoads));
|
||
});
|
||
|
||
testWidgets('community switcher header grows with accessible text', (
|
||
tester,
|
||
) async {
|
||
final community = Community(
|
||
id: 'alpha',
|
||
name: 'Alpha',
|
||
relayUrl: 'wss://alpha.example.com',
|
||
addedAt: DateTime(2025),
|
||
);
|
||
|
||
await tester.pumpWidget(
|
||
buildTestable(
|
||
textScaler: TextScaler.linear(2),
|
||
overrides: [
|
||
channelsProvider.overrideWith(() => _FakeNotifier(testChannels)),
|
||
communityListProvider.overrideWith(
|
||
() => _FakeCommunityListNotifier([community]),
|
||
),
|
||
activeCommunityProvider.overrideWith((ref) async => community),
|
||
],
|
||
),
|
||
);
|
||
await tester.pumpAndSettle();
|
||
|
||
await tester.tap(find.text('Alpha'));
|
||
await tester.pumpAndSettle();
|
||
|
||
expect(tester.takeException(), isNull);
|
||
expect(
|
||
tester.getSize(find.byKey(const Key('community-switcher-title'))).height,
|
||
greaterThan(32),
|
||
);
|
||
});
|
||
|
||
testWidgets('community switcher disables icon motion when requested', (
|
||
tester,
|
||
) async {
|
||
final communities = [
|
||
Community(
|
||
id: 'alpha',
|
||
name: 'Alpha',
|
||
relayUrl: 'wss://alpha.example.com',
|
||
addedAt: DateTime(2025),
|
||
),
|
||
];
|
||
|
||
await tester.pumpWidget(
|
||
buildTestable(
|
||
disableAnimations: true,
|
||
overrides: [
|
||
channelsProvider.overrideWith(() => _FakeNotifier(testChannels)),
|
||
communityListProvider.overrideWith(
|
||
() => _FakeCommunityListNotifier(communities),
|
||
),
|
||
activeCommunityProvider.overrideWith(
|
||
(ref) async => communities.first,
|
||
),
|
||
],
|
||
),
|
||
);
|
||
await tester.pumpAndSettle();
|
||
|
||
await tester.tap(find.text('Alpha'));
|
||
await tester.pumpAndSettle();
|
||
await tester.tap(find.text('Edit'));
|
||
await tester.pump();
|
||
|
||
final actionSwitcher = tester.widget<AnimatedSwitcher>(
|
||
find.descendant(
|
||
of: find.byKey(const Key('community-switcher-action-alpha')),
|
||
matching: find.byType(AnimatedSwitcher),
|
||
),
|
||
);
|
||
expect(actionSwitcher.duration, Duration.zero);
|
||
expect(
|
||
find.byKey(const Key('community-switcher-selection-alpha')),
|
||
findsNothing,
|
||
);
|
||
expect(
|
||
find.byKey(const Key('community-switcher-remove-alpha')),
|
||
findsOneWidget,
|
||
);
|
||
});
|
||
|
||
testWidgets('quick actions slide behind navigation when leaving home', (
|
||
tester,
|
||
) async {
|
||
Widget buildLauncher({required bool visible}) {
|
||
return ProviderScope(
|
||
overrides: [profileProvider.overrideWith(() => _FakeProfileNotifier())],
|
||
child: MaterialApp(
|
||
theme: AppTheme.light(),
|
||
home: Stack(
|
||
children: [
|
||
Positioned.fill(
|
||
child: ChannelQuickActionsLauncher(
|
||
visible: visible,
|
||
navigationBarHeight: 60,
|
||
navigationBarBottomGap: 12,
|
||
navigationBarWidth: 218,
|
||
systemBottomInset: 0,
|
||
rightInset: 16,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
await tester.pumpWidget(buildLauncher(visible: true));
|
||
await tester.pumpAndSettle();
|
||
Transform motionTransform() => tester.widget<Transform>(
|
||
find.byKey(const Key('channel-quick-actions-transform')),
|
||
);
|
||
|
||
expect(motionTransform().transform.getTranslation().x, 0);
|
||
|
||
await tester.pumpWidget(buildLauncher(visible: false));
|
||
await tester.pump();
|
||
await tester.pump(const Duration(milliseconds: 110));
|
||
final midpoint = motionTransform().transform.getTranslation().x;
|
||
expect(midpoint, lessThan(0));
|
||
expect(midpoint, greaterThan(-279));
|
||
|
||
await tester.pumpAndSettle();
|
||
expect(motionTransform().transform.getTranslation().x, closeTo(-279, 0.01));
|
||
final hiddenOpacity = tester.widget<Opacity>(
|
||
find.byKey(const Key('channel-quick-actions-opacity')),
|
||
);
|
||
expect(hiddenOpacity.opacity, 0);
|
||
final hiddenPointerGate = tester.widget<IgnorePointer>(
|
||
find
|
||
.ancestor(
|
||
of: find.byKey(const Key('channel-quick-actions-transform')),
|
||
matching: find.byType(IgnorePointer),
|
||
)
|
||
.first,
|
||
);
|
||
expect(hiddenPointerGate.ignoring, isTrue);
|
||
});
|
||
|
||
testWidgets('quick actions spring into spaced muted cards', (tester) async {
|
||
await tester.pumpWidget(
|
||
buildTestable(
|
||
overrides: [
|
||
channelsProvider.overrideWith(() => _FakeNotifier(testChannels)),
|
||
],
|
||
),
|
||
);
|
||
await tester.pumpAndSettle();
|
||
|
||
final surface = find.byKey(const Key('quick-actions-surface'));
|
||
expect(tester.getSize(surface), const Size.square(56));
|
||
|
||
await tester.tap(find.byTooltip('Create or start conversation'));
|
||
await tester.pump();
|
||
|
||
var largestHeight = tester.getSize(surface).height;
|
||
for (var frame = 0; frame < 20; frame++) {
|
||
await tester.pump(const Duration(milliseconds: 16));
|
||
largestHeight = max(largestHeight, tester.getSize(surface).height);
|
||
}
|
||
await tester.pumpAndSettle();
|
||
|
||
expect(largestHeight, greaterThan(216));
|
||
expect(tester.getSize(surface).height, closeTo(216, 0.01));
|
||
final screenWidth = MediaQuery.sizeOf(tester.element(surface)).width;
|
||
final surfaceRect = tester.getRect(surface);
|
||
expect(surfaceRect.left, closeTo(20, 0.01));
|
||
expect(surfaceRect.right, closeTo(screenWidth - 20, 0.01));
|
||
|
||
final menuRect = tester.getRect(
|
||
find.byKey(const Key('quick-actions-menu')),
|
||
);
|
||
final createCard = find.byKey(
|
||
const Key('quick-action-create-channel-card'),
|
||
);
|
||
final dmCard = find.byKey(const Key('quick-action-new-dm-card'));
|
||
final browseCard = find.byKey(
|
||
const Key('quick-action-browse-channels-card'),
|
||
);
|
||
final createRect = tester.getRect(createCard);
|
||
final dmRect = tester.getRect(dmCard);
|
||
final browseRect = tester.getRect(browseCard);
|
||
|
||
expect(createRect.left - menuRect.left, closeTo(8, 0.01));
|
||
expect(menuRect.right - createRect.right, closeTo(8, 0.01));
|
||
expect(dmRect.left - menuRect.left, closeTo(8, 0.01));
|
||
expect(menuRect.right - dmRect.right, closeTo(8, 0.01));
|
||
expect(browseRect.left - menuRect.left, closeTo(8, 0.01));
|
||
expect(menuRect.right - browseRect.right, closeTo(8, 0.01));
|
||
expect(dmRect.top - createRect.bottom, closeTo(8, 0.01));
|
||
expect(browseRect.top - dmRect.bottom, closeTo(8, 0.01));
|
||
expect(dmRect.width, createRect.width);
|
||
expect(browseRect.width, createRect.width);
|
||
expect(dmRect.width, closeTo(menuRect.width - 16, 0.01));
|
||
|
||
final cardScheme = Theme.of(tester.element(createCard)).colorScheme;
|
||
final expectedCardColor = Color.alphaBlend(
|
||
cardScheme.onPrimary.withValues(alpha: 0.1),
|
||
cardScheme.primary,
|
||
);
|
||
final createMaterial = tester.widget<Material>(
|
||
find.descendant(of: createCard, matching: find.byType(Material)).first,
|
||
);
|
||
final dmMaterial = tester.widget<Material>(
|
||
find.descendant(of: dmCard, matching: find.byType(Material)).first,
|
||
);
|
||
final browseMaterial = tester.widget<Material>(
|
||
find.descendant(of: browseCard, matching: find.byType(Material)).first,
|
||
);
|
||
expect(createMaterial.color, expectedCardColor);
|
||
expect(dmMaterial.color, expectedCardColor);
|
||
expect(browseMaterial.color, expectedCardColor);
|
||
expect(
|
||
(createMaterial.borderRadius as BorderRadius).topLeft.x,
|
||
closeTo(12, 0.01),
|
||
);
|
||
expect(
|
||
(dmMaterial.borderRadius as BorderRadius).topLeft.x,
|
||
closeTo(12, 0.01),
|
||
);
|
||
|
||
expect(find.text('Start a new stream channel'), findsNothing);
|
||
expect(
|
||
tester.widget<Text>(find.text('Create channel')).style?.fontSize,
|
||
16,
|
||
);
|
||
expect(
|
||
tester.widget<Text>(find.text('New direct message')).style?.fontSize,
|
||
16,
|
||
);
|
||
expect(
|
||
tester.widget<Text>(find.text('Browse channels')).style?.fontSize,
|
||
16,
|
||
);
|
||
expect(find.text('Message one or more people'), findsNothing);
|
||
});
|
||
|
||
testWidgets('browse action lists only channels eligible to join', (
|
||
tester,
|
||
) async {
|
||
final channels = [
|
||
...testChannels,
|
||
Channel(
|
||
id: 'open-to-join',
|
||
name: 'announcements',
|
||
channelType: 'stream',
|
||
visibility: 'open',
|
||
description: 'Community announcements',
|
||
createdBy: 'abc',
|
||
createdAt: DateTime(2025),
|
||
memberCount: 8,
|
||
),
|
||
Channel(
|
||
id: 'private-channel',
|
||
name: 'private-planning',
|
||
channelType: 'stream',
|
||
visibility: 'private',
|
||
description: 'Private planning',
|
||
createdBy: 'abc',
|
||
createdAt: DateTime(2025),
|
||
memberCount: 4,
|
||
),
|
||
Channel(
|
||
id: 'archived-channel',
|
||
name: 'old-announcements',
|
||
channelType: 'stream',
|
||
visibility: 'open',
|
||
description: 'Archived announcements',
|
||
createdBy: 'abc',
|
||
createdAt: DateTime(2025),
|
||
memberCount: 3,
|
||
archivedAt: DateTime(2025, 1, 2),
|
||
),
|
||
Channel(
|
||
id: 'unjoined-dm',
|
||
name: 'Hidden DM',
|
||
channelType: 'dm',
|
||
visibility: 'open',
|
||
description: 'Direct message',
|
||
createdBy: 'abc',
|
||
createdAt: DateTime(2025),
|
||
memberCount: 2,
|
||
),
|
||
];
|
||
|
||
await tester.pumpWidget(
|
||
buildTestable(
|
||
disableAnimations: true,
|
||
overrides: [
|
||
channelsProvider.overrideWith(() => _FakeNotifier(channels)),
|
||
],
|
||
),
|
||
);
|
||
await tester.pumpAndSettle();
|
||
|
||
await tester.tap(find.byTooltip('Create or start conversation'));
|
||
await tester.pump();
|
||
await tester.tap(
|
||
find.byKey(const Key('quick-action-browse-channels-card')),
|
||
);
|
||
await tester.pumpAndSettle();
|
||
|
||
expect(
|
||
find.byKey(const Key('browse-channel-open-to-join')),
|
||
findsOneWidget,
|
||
);
|
||
expect(find.byKey(const Key('browse-channel-1')), findsNothing);
|
||
expect(
|
||
find.byKey(const Key('browse-channel-private-channel')),
|
||
findsNothing,
|
||
);
|
||
expect(
|
||
find.byKey(const Key('browse-channel-archived-channel')),
|
||
findsNothing,
|
||
);
|
||
expect(find.byKey(const Key('browse-channel-unjoined-dm')), findsNothing);
|
||
});
|
||
|
||
testWidgets('browse action explains when no channels are discoverable', (
|
||
tester,
|
||
) async {
|
||
await tester.pumpWidget(
|
||
buildTestable(
|
||
disableAnimations: true,
|
||
overrides: [
|
||
channelsProvider.overrideWith(() => _FakeNotifier(testChannels)),
|
||
],
|
||
),
|
||
);
|
||
await tester.pumpAndSettle();
|
||
|
||
await tester.tap(find.byTooltip('Create or start conversation'));
|
||
await tester.pump();
|
||
await tester.tap(
|
||
find.byKey(const Key('quick-action-browse-channels-card')),
|
||
);
|
||
await tester.pumpAndSettle();
|
||
|
||
expect(find.text('No open channels available to join.'), findsOneWidget);
|
||
});
|
||
|
||
testWidgets('browse action retries an initial directory request problem', (
|
||
tester,
|
||
) async {
|
||
final joinable = Channel(
|
||
id: 'retry-discovery',
|
||
name: 'community-help',
|
||
channelType: 'stream',
|
||
visibility: 'open',
|
||
description: 'Help from the community',
|
||
createdBy: 'abc',
|
||
createdAt: DateTime(2025),
|
||
memberCount: 0,
|
||
);
|
||
late _RetryingDirectoryNotifier notifier;
|
||
await tester.pumpWidget(
|
||
buildTestable(
|
||
disableAnimations: true,
|
||
overrides: [
|
||
channelsProvider.overrideWith(
|
||
() => notifier = _RetryingDirectoryNotifier(
|
||
initialChannels: testChannels,
|
||
retriedChannels: [...testChannels, joinable],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
await tester.pumpAndSettle();
|
||
|
||
await tester.tap(find.byTooltip('Create or start conversation'));
|
||
await tester.pump();
|
||
await tester.tap(
|
||
find.byKey(const Key('quick-action-browse-channels-card')),
|
||
);
|
||
await tester.pumpAndSettle();
|
||
|
||
expect(find.text('Couldn’t load open channels.'), findsOneWidget);
|
||
expect(find.text('No open channels available to join.'), findsNothing);
|
||
expect(find.byKey(const Key('browse-channels-retry')), findsOneWidget);
|
||
|
||
await tester.tap(find.byKey(const Key('browse-channels-retry')));
|
||
await tester.pumpAndSettle();
|
||
|
||
expect(notifier.retryCount, 1);
|
||
expect(find.text('Couldn’t load open channels.'), findsNothing);
|
||
expect(
|
||
find.byKey(const Key('browse-channel-retry-discovery')),
|
||
findsOneWidget,
|
||
);
|
||
});
|
||
|
||
testWidgets('browse action exposes retry when refresh supersedes loading', (
|
||
tester,
|
||
) async {
|
||
final joinable = Channel(
|
||
id: 'superseded-directory',
|
||
name: 'community-help',
|
||
channelType: 'stream',
|
||
visibility: 'open',
|
||
description: 'Help from the community',
|
||
createdBy: 'abc',
|
||
createdAt: DateTime(2025),
|
||
memberCount: 0,
|
||
);
|
||
late _SupersededDirectoryNotifier notifier;
|
||
await tester.pumpWidget(
|
||
buildTestable(
|
||
disableAnimations: true,
|
||
overrides: [
|
||
channelsProvider.overrideWith(
|
||
() => notifier = _SupersededDirectoryNotifier(
|
||
initialChannels: testChannels,
|
||
retriedChannels: [...testChannels, joinable],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
await tester.pumpAndSettle();
|
||
|
||
await tester.tap(find.byTooltip('Create or start conversation'));
|
||
await tester.pump();
|
||
await tester.tap(
|
||
find.byKey(const Key('quick-action-browse-channels-card')),
|
||
);
|
||
await tester.pumpAndSettle();
|
||
|
||
expect(find.byType(BuzzLoadingIndicator), findsOneWidget);
|
||
|
||
notifier.supersedeLoadingDirectory();
|
||
await tester.pumpAndSettle();
|
||
|
||
expect(find.byType(BuzzLoadingIndicator), findsNothing);
|
||
expect(find.text('Couldn’t load open channels.'), findsOneWidget);
|
||
expect(find.byKey(const Key('browse-channels-retry')), findsOneWidget);
|
||
|
||
await tester.tap(find.byKey(const Key('browse-channels-retry')));
|
||
await tester.pumpAndSettle();
|
||
|
||
expect(notifier.retryCount, 1);
|
||
expect(
|
||
find.byKey(const Key('browse-channel-superseded-directory')),
|
||
findsOneWidget,
|
||
);
|
||
});
|
||
|
||
testWidgets('browse action scrolls and joins an offscreen channel', (
|
||
tester,
|
||
) async {
|
||
final channels = List.generate(
|
||
500,
|
||
(index) => Channel(
|
||
id: 'directory-$index',
|
||
name: 'channel-${index.toString().padLeft(3, '0')}',
|
||
channelType: 'stream',
|
||
visibility: 'open',
|
||
description: '',
|
||
createdBy: 'abc',
|
||
createdAt: DateTime(2025),
|
||
memberCount: 0,
|
||
),
|
||
);
|
||
late _RecordingChannelActions actions;
|
||
await tester.pumpWidget(
|
||
buildTestable(
|
||
disableAnimations: true,
|
||
overrides: [
|
||
channelsProvider.overrideWith(() => _FakeNotifier(channels)),
|
||
channelActionsProvider.overrideWith(
|
||
(ref) => actions = _RecordingChannelActions(ref),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
await tester.pumpAndSettle();
|
||
|
||
await tester.tap(find.byTooltip('Create or start conversation'));
|
||
await tester.pump();
|
||
await tester.tap(
|
||
find.byKey(const Key('quick-action-browse-channels-card')),
|
||
);
|
||
await tester.pumpAndSettle();
|
||
|
||
expect(
|
||
find.byKey(const Key('browse-channel-directory-0')),
|
||
findsAtLeast(1),
|
||
);
|
||
expect(find.byKey(const Key('browse-channel-directory-499')), findsNothing);
|
||
|
||
final sheet = find.byType(BottomSheet).last;
|
||
final scrollable = find
|
||
.descendant(of: sheet, matching: find.byType(Scrollable))
|
||
.last;
|
||
expect(
|
||
tester.state<ScrollableState>(scrollable).position.maxScrollExtent,
|
||
greaterThan(0),
|
||
);
|
||
await tester.scrollUntilVisible(
|
||
find.byKey(const Key('browse-channel-directory-499')),
|
||
500,
|
||
scrollable: scrollable,
|
||
maxScrolls: 100,
|
||
);
|
||
await tester.pumpAndSettle();
|
||
|
||
expect(
|
||
find.byKey(const Key('browse-channel-directory-499')),
|
||
findsOneWidget,
|
||
);
|
||
expect(find.byKey(const Key('browse-channel-directory-0')), findsNothing);
|
||
|
||
await tester.tap(
|
||
find.byKey(const Key('browse-channel-join-directory-499')),
|
||
);
|
||
await tester.pumpAndSettle();
|
||
|
||
expect(actions.joinedChannelIds, ['directory-499']);
|
||
expect(find.byType(BottomSheet), findsNothing);
|
||
});
|
||
|
||
testWidgets('create channel sheet lists type and visibility radio options', (
|
||
tester,
|
||
) async {
|
||
await tester.pumpWidget(
|
||
buildTestable(
|
||
overrides: [
|
||
channelsProvider.overrideWith(() => _FakeNotifier(testChannels)),
|
||
],
|
||
),
|
||
);
|
||
await tester.pumpAndSettle();
|
||
|
||
await tester.tap(find.byTooltip('Create or start conversation'));
|
||
await tester.pumpAndSettle();
|
||
await tester.tap(find.text('Create channel'));
|
||
await tester.pumpAndSettle();
|
||
final createSheetRect = tester.getRect(find.byType(BottomSheet).last);
|
||
expect(createSheetRect.top, greaterThanOrEqualTo(24));
|
||
tester.testTextInput.hide();
|
||
await tester.pumpAndSettle();
|
||
|
||
expect(find.text('Create a new channel'), findsOneWidget);
|
||
expect(find.text('Name'), findsOneWidget);
|
||
expect(find.text('Description Optional'), findsOneWidget);
|
||
expect(find.text('Channel type'), findsOneWidget);
|
||
expect(find.text('Ongoing'), findsOneWidget);
|
||
expect(find.text('Temporary'), findsOneWidget);
|
||
expect(find.text('Visibility'), findsOneWidget);
|
||
expect(find.text('Public'), findsOneWidget);
|
||
expect(find.text('Private'), findsOneWidget);
|
||
expect(find.byKey(const Key('create-channel-submit')), findsNothing);
|
||
final nameField = tester.widget<TextField>(
|
||
find.byKey(const Key('create-channel-name')),
|
||
);
|
||
final descriptionField = tester.widget<TextField>(
|
||
find.byKey(const Key('create-channel-description')),
|
||
);
|
||
expect(nameField.textInputAction, TextInputAction.done);
|
||
expect(nameField.onSubmitted, isNotNull);
|
||
expect(descriptionField.textInputAction, TextInputAction.done);
|
||
expect(descriptionField.onSubmitted, isNotNull);
|
||
expect(
|
||
tester.getSize(find.byKey(const Key('create-channel-name'))).height,
|
||
greaterThanOrEqualTo(52),
|
||
);
|
||
expect(
|
||
tester
|
||
.getSize(find.byKey(const Key('create-channel-type-ongoing')))
|
||
.height,
|
||
greaterThanOrEqualTo(56),
|
||
);
|
||
|
||
await tester.tap(find.byKey(const Key('create-channel-type-temporary')));
|
||
await tester.pumpAndSettle();
|
||
|
||
expect(find.text('Expires after'), findsOneWidget);
|
||
expect(find.text('7 days'), findsOneWidget);
|
||
expect(
|
||
tester.getSize(find.byKey(const Key('create-channel-ttl'))).height,
|
||
greaterThanOrEqualTo(51),
|
||
);
|
||
|
||
final privateVisibility = find.byKey(
|
||
const Key('create-channel-visibility-private'),
|
||
);
|
||
await tester.ensureVisible(privateVisibility);
|
||
await tester.pumpAndSettle();
|
||
await tester.tap(privateVisibility);
|
||
await tester.pumpAndSettle();
|
||
expect(
|
||
tester
|
||
.widget<RadioGroup<String>>(find.byType(RadioGroup<String>))
|
||
.groupValue,
|
||
'private',
|
||
);
|
||
});
|
||
|
||
testWidgets('new message sheet lists and selects relay members', (
|
||
tester,
|
||
) async {
|
||
tester.view.physicalSize = const Size(390, 800);
|
||
tester.view.devicePixelRatio = 1;
|
||
addTearDown(tester.view.resetPhysicalSize);
|
||
addTearDown(tester.view.resetDevicePixelRatio);
|
||
|
||
const directoryUsers = [
|
||
DirectoryUser(
|
||
pubkey: 'alice',
|
||
displayName: 'Alice',
|
||
nip05Handle: 'alice@example.com',
|
||
),
|
||
DirectoryUser(pubkey: 'bob', displayName: 'Bob'),
|
||
DirectoryUser(pubkey: 'charlie', displayName: 'Charlie'),
|
||
DirectoryUser(pubkey: 'danielle', displayName: 'Danielle'),
|
||
];
|
||
await tester.pumpWidget(
|
||
buildTestable(
|
||
overrides: [
|
||
channelsProvider.overrideWith(() => _FakeNotifier(testChannels)),
|
||
relayDirectoryUsersProvider.overrideWith(
|
||
(ref) async => directoryUsers,
|
||
),
|
||
relayDirectorySearchProvider.overrideWith((ref, query) async {
|
||
return directoryUsers
|
||
.where(
|
||
(user) =>
|
||
user.label.toLowerCase().contains(query.toLowerCase()),
|
||
)
|
||
.toList();
|
||
}),
|
||
],
|
||
),
|
||
);
|
||
await tester.pumpAndSettle();
|
||
|
||
await tester.tap(find.byTooltip('Create or start conversation'));
|
||
await tester.pumpAndSettle();
|
||
await tester.tap(find.text('New direct message'));
|
||
await tester.pumpAndSettle();
|
||
|
||
final dmSheetRect = tester.getRect(find.byType(BottomSheet).last);
|
||
expect(dmSheetRect.top, greaterThanOrEqualTo(24));
|
||
expect(find.text('New message'), findsOneWidget);
|
||
expect(find.text('To:'), findsOneWidget);
|
||
expect(find.text('Search for a person'), findsOneWidget);
|
||
final recipientField = find.byKey(const Key('new-dm-recipient-field'));
|
||
final initialRecipientWidth = tester.getSize(recipientField).width;
|
||
expect(tester.getSize(recipientField).height, greaterThanOrEqualTo(48));
|
||
expect(find.byKey(const Key('new-dm-person-alice')), findsOneWidget);
|
||
expect(find.byKey(const Key('new-dm-person-bob')), findsOneWidget);
|
||
expect(
|
||
find.descendant(
|
||
of: find.byKey(const Key('new-dm-person-alice')),
|
||
matching: find.byType(AvatarImage),
|
||
),
|
||
findsOneWidget,
|
||
);
|
||
|
||
await tester.enterText(find.byKey(const Key('new-dm-search')), 'bob');
|
||
await tester.pump(const Duration(milliseconds: 300));
|
||
await tester.pumpAndSettle();
|
||
expect(find.byKey(const Key('new-dm-person-alice')), findsNothing);
|
||
expect(find.byKey(const Key('new-dm-person-bob')), findsOneWidget);
|
||
|
||
await tester.enterText(find.byKey(const Key('new-dm-search')), '');
|
||
await tester.pump(const Duration(milliseconds: 300));
|
||
await tester.pumpAndSettle();
|
||
await tester.tap(find.byKey(const Key('new-dm-person-alice')));
|
||
await tester.pump();
|
||
|
||
final aliceChip = find.byKey(const Key('new-dm-selected-alice'));
|
||
final inlineSearch = find.byKey(const Key('new-dm-search'));
|
||
final aliceRect = tester.getRect(aliceChip);
|
||
final inlineSearchRect = tester.getRect(inlineSearch);
|
||
expect(inlineSearchRect.left, greaterThan(aliceRect.right));
|
||
expect(
|
||
(inlineSearchRect.center.dy - aliceRect.center.dy).abs(),
|
||
lessThan(1),
|
||
);
|
||
expect(find.text('Search for a person'), findsNothing);
|
||
expect(tester.widget<TextField>(inlineSearch).focusNode?.hasFocus, isTrue);
|
||
expect(tester.getSize(recipientField).width, initialRecipientWidth);
|
||
expect(tester.getSize(aliceChip).height, 40);
|
||
expect(tester.getSize(aliceChip).width, lessThanOrEqualTo(224));
|
||
final aliceChipMaterial = tester.widget<Material>(
|
||
find.descendant(of: aliceChip, matching: find.byType(Material)).first,
|
||
);
|
||
expect(aliceChipMaterial.shape, isA<StadiumBorder>());
|
||
expect(
|
||
aliceChipMaterial.color,
|
||
Theme.of(tester.element(aliceChip)).colorScheme.surfaceContainerHighest,
|
||
);
|
||
expect(
|
||
tester
|
||
.widget<AvatarImage>(
|
||
find.descendant(of: aliceChip, matching: find.byType(AvatarImage)),
|
||
)
|
||
.radius,
|
||
16,
|
||
);
|
||
expect(
|
||
tester
|
||
.widget<Text>(
|
||
find.descendant(of: aliceChip, matching: find.text('Alice')),
|
||
)
|
||
.style
|
||
?.fontSize,
|
||
16,
|
||
);
|
||
expect(
|
||
find.descendant(of: aliceChip, matching: find.byIcon(LucideIcons.x)),
|
||
findsNothing,
|
||
);
|
||
expect(find.bySemanticsLabel('Remove Alice'), findsOneWidget);
|
||
|
||
await tester.tap(find.byKey(const Key('new-dm-person-bob')));
|
||
await tester.pump();
|
||
await tester.tap(find.byKey(const Key('new-dm-person-charlie')));
|
||
await tester.pump();
|
||
await tester.tap(find.byKey(const Key('new-dm-person-danielle')));
|
||
await tester.pump();
|
||
|
||
expect(aliceChip, findsOneWidget);
|
||
expect(find.byKey(const Key('new-dm-selected-bob')), findsOneWidget);
|
||
expect(find.byKey(const Key('new-dm-selected-charlie')), findsOneWidget);
|
||
final danielleChip = find.byKey(const Key('new-dm-selected-danielle'));
|
||
expect(danielleChip, findsOneWidget);
|
||
expect(tester.getSize(recipientField).width, initialRecipientWidth);
|
||
expect(
|
||
tester.getRect(danielleChip).top,
|
||
greaterThan(tester.getRect(aliceChip).top),
|
||
);
|
||
final recipientScrollViews = tester.widgetList<SingleChildScrollView>(
|
||
find.ancestor(
|
||
of: aliceChip,
|
||
matching: find.byType(SingleChildScrollView),
|
||
),
|
||
);
|
||
expect(
|
||
recipientScrollViews.every(
|
||
(scrollView) => scrollView.scrollDirection == Axis.vertical,
|
||
),
|
||
isTrue,
|
||
);
|
||
expect(
|
||
find.ancestor(
|
||
of: aliceChip,
|
||
matching: find.byKey(const Key('new-dm-recipient-wrap')),
|
||
),
|
||
findsOneWidget,
|
||
);
|
||
await tester.tap(find.byKey(const Key('new-dm-selected-bob')));
|
||
await tester.pump();
|
||
expect(find.byKey(const Key('new-dm-selected-bob')), findsNothing);
|
||
expect(find.byKey(const Key('new-dm-person-bob')), findsOneWidget);
|
||
expect(tester.widget<TextField>(inlineSearch).focusNode?.hasFocus, isTrue);
|
||
expect(find.text('Cancel'), findsNothing);
|
||
expect(find.text('Open DM'), findsNothing);
|
||
final searchField = tester.widget<TextField>(
|
||
find.byKey(const Key('new-dm-search')),
|
||
);
|
||
expect(searchField.textInputAction, TextInputAction.done);
|
||
expect(searchField.onSubmitted, isNotNull);
|
||
});
|
||
|
||
testWidgets('wraps many recipients without overflowing above the keyboard', (
|
||
tester,
|
||
) async {
|
||
tester.view.physicalSize = const Size(390, 800);
|
||
tester.view.devicePixelRatio = 1;
|
||
addTearDown(tester.view.resetPhysicalSize);
|
||
addTearDown(tester.view.resetDevicePixelRatio);
|
||
|
||
final directoryUsers = [
|
||
for (var index = 0; index < 8; index++)
|
||
DirectoryUser(
|
||
pubkey: 'person-$index',
|
||
displayName: 'Long recipient name number $index',
|
||
),
|
||
];
|
||
|
||
await tester.pumpWidget(
|
||
buildTestable(
|
||
keyboardInset: 300,
|
||
overrides: [
|
||
channelsProvider.overrideWith(() => _FakeNotifier(testChannels)),
|
||
relayDirectoryUsersProvider.overrideWith(
|
||
(ref) async => directoryUsers,
|
||
),
|
||
],
|
||
),
|
||
);
|
||
await tester.pumpAndSettle();
|
||
|
||
await tester.tap(find.byTooltip('Create or start conversation'));
|
||
await tester.pumpAndSettle();
|
||
await tester.tap(find.text('New direct message'));
|
||
await tester.pumpAndSettle();
|
||
|
||
for (final user in directoryUsers) {
|
||
final result = find.byKey(Key('new-dm-person-${user.pubkey}'));
|
||
await tester.ensureVisible(result);
|
||
await tester.pumpAndSettle();
|
||
await tester.tap(result);
|
||
await tester.pumpAndSettle();
|
||
}
|
||
|
||
expect(find.byKey(const Key('new-dm-recipient-wrap')), findsOneWidget);
|
||
expect(
|
||
find.bySemanticsLabel(RegExp(r'^Remove Long recipient')),
|
||
findsNWidgets(8),
|
||
);
|
||
expect(
|
||
find.text('DMs support up to nine people, including you.'),
|
||
findsOneWidget,
|
||
);
|
||
expect(tester.takeException(), isNull);
|
||
});
|
||
|
||
testWidgets('shows local preview people when the relay is offline', (
|
||
tester,
|
||
) async {
|
||
tester.view.physicalSize = const Size(390, 800);
|
||
tester.view.devicePixelRatio = 1;
|
||
addTearDown(tester.view.resetPhysicalSize);
|
||
addTearDown(tester.view.resetDevicePixelRatio);
|
||
|
||
await tester.pumpWidget(
|
||
buildTestable(
|
||
previewDirectory: true,
|
||
overrides: [
|
||
channelsProvider.overrideWith(() => _FakeNotifier(testChannels)),
|
||
],
|
||
),
|
||
);
|
||
await tester.pumpAndSettle();
|
||
|
||
await tester.tap(find.byTooltip('Create or start conversation'));
|
||
await tester.pumpAndSettle();
|
||
await tester.tap(find.text('New direct message'));
|
||
await tester.pumpAndSettle();
|
||
|
||
expect(find.text('Could not load people from this relay.'), findsNothing);
|
||
expect(find.text('Maya Chen'), findsOneWidget);
|
||
expect(find.text('Jordan Brooks'), findsOneWidget);
|
||
expect(find.text('Priya Shah'), findsOneWidget);
|
||
|
||
await tester.tap(
|
||
find.byKey(
|
||
const Key(
|
||
'new-dm-person-'
|
||
'1111111111111111111111111111111111111111111111111111111111111111',
|
||
),
|
||
),
|
||
);
|
||
await tester.pump();
|
||
expect(find.text('Maya Chen'), findsWidgets);
|
||
|
||
await tester.testTextInput.receiveAction(TextInputAction.done);
|
||
await tester.pump();
|
||
expect(
|
||
find.text('Preview only — mock people cannot be messaged.'),
|
||
findsOneWidget,
|
||
);
|
||
});
|
||
|
||
testWidgets('hides unjoined and archived channels from the main list', (
|
||
tester,
|
||
) async {
|
||
final channels = [
|
||
...testChannels,
|
||
Channel(
|
||
id: '4',
|
||
name: 'open-stream',
|
||
channelType: 'stream',
|
||
visibility: 'open',
|
||
description: 'Available to join',
|
||
createdBy: 'abc',
|
||
createdAt: DateTime(2025),
|
||
memberCount: 8,
|
||
isMember: false,
|
||
),
|
||
Channel(
|
||
id: '5',
|
||
name: 'archived-stream',
|
||
channelType: 'stream',
|
||
visibility: 'open',
|
||
description: 'Archived channel',
|
||
createdBy: 'abc',
|
||
createdAt: DateTime(2025),
|
||
memberCount: 4,
|
||
isMember: true,
|
||
archivedAt: DateTime(2025, 1, 2),
|
||
),
|
||
];
|
||
|
||
await tester.pumpWidget(
|
||
buildTestable(
|
||
overrides: [
|
||
channelsProvider.overrideWith(() => _FakeNotifier(channels)),
|
||
],
|
||
),
|
||
);
|
||
await tester.pumpAndSettle();
|
||
|
||
// Unjoined and archived channels should not appear in the main list.
|
||
expect(find.text('general'), findsOneWidget);
|
||
expect(find.text('open-stream'), findsNothing);
|
||
expect(find.text('archived-stream'), findsNothing);
|
||
});
|
||
|
||
testWidgets('empty state does not preview unjoined channels', (tester) async {
|
||
final discoveredChannel = Channel(
|
||
id: 'discovered-channel',
|
||
name: 'community-help',
|
||
channelType: 'stream',
|
||
visibility: 'open',
|
||
description: 'Get help from the community',
|
||
createdBy: 'abc',
|
||
createdAt: DateTime(2025),
|
||
memberCount: 7,
|
||
);
|
||
await tester.pumpWidget(
|
||
buildTestable(
|
||
overrides: [
|
||
channelsProvider.overrideWith(
|
||
() => _FakeNotifier([discoveredChannel]),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
await tester.pumpAndSettle();
|
||
|
||
expect(find.text('No conversations yet'), findsOneWidget);
|
||
expect(
|
||
find.text('Join an open channel to start a conversation.'),
|
||
findsNothing,
|
||
);
|
||
expect(
|
||
find.byKey(const Key('browse-channel-discovered-channel')),
|
||
findsNothing,
|
||
);
|
||
});
|
||
|
||
testWidgets('shows error view with retry button', (tester) async {
|
||
await tester.pumpWidget(
|
||
buildTestable(
|
||
overrides: [channelsProvider.overrideWith(() => _ErrorNotifier())],
|
||
),
|
||
);
|
||
// The error view is gated on a grace timer in ChannelsPage to absorb
|
||
// transient AsyncError frames during relay reconnect. Pump once to mount
|
||
// and schedule the timer, advance the fake clock past the grace window,
|
||
// then pump again to flush the setState the timer triggered.
|
||
await tester.pump();
|
||
await tester.pump(const Duration(seconds: 3));
|
||
await tester.pump();
|
||
|
||
expect(find.text('Could not load channels'), findsOneWidget);
|
||
expect(find.text('Retry'), findsOneWidget);
|
||
});
|
||
|
||
testWidgets('bolds and clears unread channel labels', (tester) async {
|
||
final channels = [
|
||
Channel(
|
||
id: '1',
|
||
name: 'general',
|
||
channelType: 'stream',
|
||
visibility: 'open',
|
||
description: 'General discussion',
|
||
createdBy: 'abc',
|
||
createdAt: DateTime(2025),
|
||
memberCount: 10,
|
||
lastMessageAt: DateTime.fromMillisecondsSinceEpoch(
|
||
20 * 1000,
|
||
isUtc: true,
|
||
),
|
||
isMember: true,
|
||
),
|
||
];
|
||
final readState = _FakeReadStateNotifier(
|
||
const ReadStateState(
|
||
isReady: true,
|
||
pubkey: 'pk',
|
||
contexts: {'1': 10},
|
||
version: 0,
|
||
),
|
||
);
|
||
|
||
await tester.pumpWidget(
|
||
buildTestable(
|
||
overrides: [
|
||
channelsProvider.overrideWith(
|
||
() => _FakeNotifier(
|
||
channels,
|
||
observedEventsByChannel: {
|
||
'1': [_observed(id: 'msg-1', createdAt: 20)],
|
||
},
|
||
),
|
||
),
|
||
readStateProvider.overrideWith(() => readState),
|
||
],
|
||
),
|
||
);
|
||
await tester.pumpAndSettle();
|
||
|
||
expect(
|
||
tester.widget<Text>(find.text('general')).style?.fontWeight,
|
||
FontWeight.w700,
|
||
);
|
||
expect(
|
||
tester.widget<Text>(find.text('general')).style?.color,
|
||
Theme.of(tester.element(find.text('general'))).colorScheme.onSurface,
|
||
);
|
||
|
||
readState.markContextRead('1', 20);
|
||
await tester.pump();
|
||
|
||
expect(
|
||
tester.widget<Text>(find.text('general')).style?.fontWeight,
|
||
FontWeight.w400,
|
||
);
|
||
});
|
||
|
||
testWidgets('bolds channels with unread thread activity without a badge', (
|
||
tester,
|
||
) async {
|
||
final channels = [
|
||
Channel(
|
||
id: '1',
|
||
name: 'general',
|
||
channelType: 'stream',
|
||
visibility: 'open',
|
||
description: 'General discussion',
|
||
createdBy: 'abc',
|
||
createdAt: DateTime(2025),
|
||
memberCount: 10,
|
||
lastMessageAt: DateTime.fromMillisecondsSinceEpoch(
|
||
30 * 1000,
|
||
isUtc: true,
|
||
),
|
||
isMember: true,
|
||
),
|
||
];
|
||
final readState = _FakeReadStateNotifier(
|
||
const ReadStateState(
|
||
isReady: true,
|
||
pubkey: 'pk',
|
||
contexts: {'1': 10},
|
||
version: 0,
|
||
),
|
||
);
|
||
|
||
await tester.pumpWidget(
|
||
buildTestable(
|
||
overrides: [
|
||
channelsProvider.overrideWith(
|
||
() => _FakeNotifier(
|
||
channels,
|
||
observedEventsByChannel: {
|
||
'1': [
|
||
_observed(
|
||
id: 'reply-1',
|
||
createdAt: 20,
|
||
rootId: 'root',
|
||
isThreadedReply: true,
|
||
),
|
||
_observed(
|
||
id: 'reply-2',
|
||
createdAt: 30,
|
||
rootId: 'root',
|
||
isThreadedReply: true,
|
||
),
|
||
],
|
||
},
|
||
),
|
||
),
|
||
readStateProvider.overrideWith(() => readState),
|
||
],
|
||
),
|
||
);
|
||
await tester.pumpAndSettle();
|
||
|
||
expect(
|
||
tester.widget<Text>(find.text('general')).style?.fontWeight,
|
||
FontWeight.w700,
|
||
);
|
||
});
|
||
|
||
testWidgets('seeds first loaded channels as read', (tester) async {
|
||
final channels = [
|
||
Channel(
|
||
id: '1',
|
||
name: 'general',
|
||
channelType: 'stream',
|
||
visibility: 'open',
|
||
description: 'General discussion',
|
||
createdBy: 'abc',
|
||
createdAt: DateTime(2025),
|
||
memberCount: 10,
|
||
lastMessageAt: DateTime.fromMillisecondsSinceEpoch(
|
||
20 * 1000,
|
||
isUtc: true,
|
||
),
|
||
isMember: true,
|
||
),
|
||
];
|
||
final readState = _FakeReadStateNotifier(
|
||
const ReadStateState(
|
||
isReady: true,
|
||
pubkey: 'pk',
|
||
contexts: {},
|
||
version: 0,
|
||
),
|
||
);
|
||
|
||
await tester.pumpWidget(
|
||
buildTestable(
|
||
overrides: [
|
||
channelsProvider.overrideWith(() => _FakeNotifier(channels)),
|
||
readStateProvider.overrideWith(() => readState),
|
||
],
|
||
),
|
||
);
|
||
await tester.pumpAndSettle();
|
||
|
||
expect(readState.seededContexts, {'1': 20});
|
||
expect(readState.markedContexts, isEmpty);
|
||
expect(
|
||
tester.widget<Text>(find.text('general')).style?.fontWeight,
|
||
FontWeight.w400,
|
||
);
|
||
});
|
||
|
||
testWidgets('waits for read-state readiness before initial seeding', (
|
||
tester,
|
||
) async {
|
||
final channels = [
|
||
Channel(
|
||
id: '1',
|
||
name: 'general',
|
||
channelType: 'stream',
|
||
visibility: 'open',
|
||
description: 'General discussion',
|
||
createdBy: 'abc',
|
||
createdAt: DateTime(2025),
|
||
memberCount: 10,
|
||
lastMessageAt: DateTime.fromMillisecondsSinceEpoch(
|
||
20 * 1000,
|
||
isUtc: true,
|
||
),
|
||
isMember: true,
|
||
),
|
||
];
|
||
final readState = _FakeReadStateNotifier(
|
||
const ReadStateState(
|
||
isReady: false,
|
||
pubkey: 'pk',
|
||
contexts: {},
|
||
version: 0,
|
||
),
|
||
);
|
||
|
||
await tester.pumpWidget(
|
||
buildTestable(
|
||
overrides: [
|
||
channelsProvider.overrideWith(() => _FakeNotifier(channels)),
|
||
readStateProvider.overrideWith(() => readState),
|
||
],
|
||
),
|
||
);
|
||
await tester.pumpAndSettle();
|
||
|
||
expect(readState.seededContexts, isEmpty);
|
||
expect(readState.markedContexts, isEmpty);
|
||
|
||
readState.setReady();
|
||
await tester.pumpAndSettle();
|
||
|
||
expect(readState.seededContexts, {'1': 20});
|
||
expect(readState.markedContexts, isEmpty);
|
||
});
|
||
}
|
||
|
||
Widget _buildSettingsPage(BuildContext context) =>
|
||
const Scaffold(body: Text('Injected settings'));
|
||
|
||
class _FakeNotifier extends ChannelsNotifier {
|
||
final List<Channel> _channels;
|
||
final Map<String, Map<String, ObservedUnreadEvent>> _observedEventsByChannel;
|
||
|
||
_FakeNotifier(
|
||
this._channels, {
|
||
Map<String, List<ObservedUnreadEvent>> observedEventsByChannel = const {},
|
||
}) : _observedEventsByChannel = {
|
||
for (final entry in observedEventsByChannel.entries)
|
||
entry.key: {for (final event in entry.value) event.id: event},
|
||
};
|
||
|
||
@override
|
||
Future<List<Channel>> build() async => _channels;
|
||
|
||
@override
|
||
Future<void> ensureDirectoryLoaded() async {
|
||
ref
|
||
.read(channelDirectoryLoadStatusProvider.notifier)
|
||
.markLoaded(_activeDirectoryScope(ref));
|
||
}
|
||
|
||
@override
|
||
Map<String, int> get latestObservedByChannel => {
|
||
for (final entry in _observedEventsByChannel.entries)
|
||
if (entry.value.isNotEmpty)
|
||
entry.key: entry.value.values
|
||
.map((event) => event.createdAt)
|
||
.reduce((left, right) => left > right ? left : right),
|
||
};
|
||
|
||
@override
|
||
Map<String, Map<String, ObservedUnreadEvent>>
|
||
get observedUnreadEventsByChannel => _observedEventsByChannel;
|
||
}
|
||
|
||
class _RetryingDirectoryNotifier extends ChannelsNotifier {
|
||
_RetryingDirectoryNotifier({
|
||
required this.initialChannels,
|
||
required this.retriedChannels,
|
||
});
|
||
|
||
final List<Channel> initialChannels;
|
||
final List<Channel> retriedChannels;
|
||
int retryCount = 0;
|
||
|
||
@override
|
||
Future<List<Channel>> build() async => initialChannels;
|
||
|
||
@override
|
||
Future<void> ensureDirectoryLoaded() async {
|
||
ref
|
||
.read(channelDirectoryLoadStatusProvider.notifier)
|
||
.markError(_activeDirectoryScope(ref));
|
||
}
|
||
|
||
@override
|
||
Future<void> retryDirectory() async {
|
||
retryCount++;
|
||
ref
|
||
.read(channelDirectoryLoadStatusProvider.notifier)
|
||
.markLoading(_activeDirectoryScope(ref));
|
||
await Future<void>.delayed(Duration.zero);
|
||
state = AsyncData(retriedChannels);
|
||
ref
|
||
.read(channelDirectoryLoadStatusProvider.notifier)
|
||
.markLoaded(_activeDirectoryScope(ref));
|
||
}
|
||
}
|
||
|
||
class _SupersededDirectoryNotifier extends ChannelsNotifier {
|
||
_SupersededDirectoryNotifier({
|
||
required this.initialChannels,
|
||
required this.retriedChannels,
|
||
});
|
||
|
||
final List<Channel> initialChannels;
|
||
final List<Channel> retriedChannels;
|
||
final _directoryCompletion = Completer<void>();
|
||
int retryCount = 0;
|
||
|
||
@override
|
||
Future<List<Channel>> build() async => initialChannels;
|
||
|
||
@override
|
||
Future<void> ensureDirectoryLoaded() async {
|
||
ref
|
||
.read(channelDirectoryLoadStatusProvider.notifier)
|
||
.markLoading(_activeDirectoryScope(ref));
|
||
await _directoryCompletion.future;
|
||
}
|
||
|
||
/// Mirrors an ordinary refresh invalidating the active directory request.
|
||
void supersedeLoadingDirectory() {
|
||
ref
|
||
.read(channelDirectoryLoadStatusProvider.notifier)
|
||
.markError(_activeDirectoryScope(ref));
|
||
_directoryCompletion.complete();
|
||
}
|
||
|
||
@override
|
||
Future<void> retryDirectory() async {
|
||
retryCount++;
|
||
state = AsyncData(retriedChannels);
|
||
ref
|
||
.read(channelDirectoryLoadStatusProvider.notifier)
|
||
.markLoaded(_activeDirectoryScope(ref));
|
||
}
|
||
}
|
||
|
||
String _activeDirectoryScope(Ref ref) => channelDirectoryScope(
|
||
ref.read(relayConfigProvider).baseUrl,
|
||
ref.read(myPubkeyProvider),
|
||
);
|
||
|
||
class _RecordingChannelActions extends ChannelActions {
|
||
_RecordingChannelActions(Ref ref)
|
||
: super(
|
||
ref: ref,
|
||
session: ref.read(relaySessionProvider.notifier),
|
||
signedEventRelay: SignedEventRelay(
|
||
session: ref.read(relaySessionProvider.notifier),
|
||
nsec: null,
|
||
),
|
||
currentPubkey: 'self',
|
||
);
|
||
|
||
final List<String> joinedChannelIds = [];
|
||
|
||
@override
|
||
Future<void> joinChannel(String channelId) async {
|
||
joinedChannelIds.add(channelId);
|
||
}
|
||
}
|
||
|
||
class _FakeChannelSectionsNotifier extends ChannelSectionsNotifier {
|
||
_FakeChannelSectionsNotifier(this._store);
|
||
|
||
final ChannelSectionStore _store;
|
||
|
||
@override
|
||
ChannelSectionsState build() =>
|
||
ChannelSectionsState(isReady: true, store: _store, version: 1);
|
||
}
|
||
|
||
class _FakeCommunityListNotifier extends CommunityListNotifier {
|
||
_FakeCommunityListNotifier(this._communities);
|
||
|
||
List<Community> _communities;
|
||
final List<String> removedIds = [];
|
||
|
||
@override
|
||
Future<List<Community>> build() async => _communities;
|
||
|
||
@override
|
||
Future<void> removeCommunity(String id) async {
|
||
removedIds.add(id);
|
||
_communities = _communities
|
||
.where((community) => community.id != id)
|
||
.toList();
|
||
state = AsyncData(_communities);
|
||
}
|
||
}
|
||
|
||
class _ErrorNotifier extends ChannelsNotifier {
|
||
@override
|
||
Future<List<Channel>> build() => Future.error('Connection refused');
|
||
}
|
||
|
||
class _LoadingNotifier extends ChannelsNotifier {
|
||
@override
|
||
Future<List<Channel>> build() => Completer<List<Channel>>().future;
|
||
}
|
||
|
||
class _ReconnectingRelaySession extends RelaySessionNotifier {
|
||
final SessionStatus initialStatus;
|
||
|
||
_ReconnectingRelaySession({this.initialStatus = SessionStatus.reconnecting});
|
||
|
||
@override
|
||
SessionState build() => SessionState(status: initialStatus);
|
||
|
||
@override
|
||
Future<List<NostrEvent>> fetchHistory(
|
||
NostrFilter filter, {
|
||
Duration timeout = const Duration(seconds: 8),
|
||
}) async => [];
|
||
|
||
@override
|
||
Future<void Function()> subscribe(
|
||
NostrFilter filter,
|
||
void Function(NostrEvent) onEvent, {
|
||
void Function(String message)? onClosed,
|
||
}) async => () {};
|
||
|
||
void connect() {
|
||
state = const SessionState(status: SessionStatus.connected);
|
||
}
|
||
|
||
void setReconnecting() {
|
||
state = const SessionState(status: SessionStatus.reconnecting);
|
||
}
|
||
}
|
||
|
||
class _FakeProfileNotifier extends ProfileNotifier {
|
||
_FakeProfileNotifier({this.pubkey = 'aabb'});
|
||
|
||
/// Current-user pubkey; the default keeps the historical 'aabb' fake used
|
||
/// by the other tile tests. The display name stays 'Test'.
|
||
final String pubkey;
|
||
|
||
@override
|
||
Future<UserProfile?> build() async =>
|
||
UserProfile(pubkey: pubkey, displayName: 'Test');
|
||
}
|
||
|
||
class _FakePresenceNotifier extends PresenceNotifier {
|
||
@override
|
||
Future<String> build() async => 'online';
|
||
}
|
||
|
||
class _FakeReadStateNotifier extends ReadStateNotifier {
|
||
final ReadStateState _initialState;
|
||
final Map<String, int> seededContexts = {};
|
||
final Map<String, int> markedContexts = {};
|
||
|
||
_FakeReadStateNotifier(this._initialState);
|
||
|
||
@override
|
||
ReadStateState build() => _initialState;
|
||
|
||
void setReady() {
|
||
state = ReadStateState(
|
||
isReady: true,
|
||
pubkey: state.pubkey,
|
||
contexts: state.contexts,
|
||
version: state.version + 1,
|
||
forcedUnreadContexts: state.forcedUnreadContexts,
|
||
);
|
||
}
|
||
|
||
@override
|
||
void seedContextRead(String contextId, int unixTimestamp) {
|
||
seededContexts[contextId] = unixTimestamp;
|
||
state = state.copyWithContext(contextId, unixTimestamp);
|
||
}
|
||
|
||
@override
|
||
void markContextRead(
|
||
String contextId,
|
||
int unixTimestamp, {
|
||
bool clearForcedMessages = false,
|
||
}) {
|
||
markedContexts[contextId] = unixTimestamp;
|
||
state = state.copyWithContext(contextId, unixTimestamp);
|
||
}
|
||
}
|
||
|
||
ObservedUnreadEvent _observed({
|
||
required String id,
|
||
required int createdAt,
|
||
String? rootId,
|
||
bool highPriority = false,
|
||
bool isThreadedReply = false,
|
||
}) => makeObservedUnreadEvent(
|
||
id: id,
|
||
createdAt: createdAt,
|
||
rootId: rootId,
|
||
highPriority: highPriority,
|
||
channelType: 'stream',
|
||
isThreadedReply: isThreadedReply,
|
||
);
|
||
|
||
/// The channel tile (its `InkWell`) that renders [labelText] as its row
|
||
/// label.
|
||
Finder _dmTileFor(String labelText) => find
|
||
.ancestor(of: find.text(labelText), matching: find.byType(InkWell))
|
||
.first;
|
||
|
||
/// Avatar fallback initial for the DM tile rendering [labelText] — asserts at
|
||
/// the production seam (the tile's `_DmAvatar`), not the label helper.
|
||
String _dmTileAvatarInitial(WidgetTester tester, String labelText) {
|
||
final tile = _dmTileFor(labelText);
|
||
final avatar = find.descendant(of: tile, matching: find.byType(AvatarImage));
|
||
final initial = tester.widget<Text>(
|
||
find.descendant(of: avatar, matching: find.byType(Text)),
|
||
);
|
||
return initial.data!;
|
||
}
|