Files
buzz/mobile/test/features/activity/activity_page_test.dart
T
klopez4212andGitHub 4555899ab2 Polish mobile navigation and menus (#3486)
## Summary
- Add a shared footer fade behind the floating tabs on Home, Activity,
and Search.
- Use a shared anchored popover for Activity filters and section
actions, with working section move controls.
- Polish message grouping/press states and remove the initial Search
back button.
<img width="630" height="1368" alt="Screenshot 2026-07-29 at 08 49 37"
src="https://github.com/user-attachments/assets/9e787adf-0bb3-49c6-8224-5819e8cfb1ad"
/>

### Testing
- `flutter analyze`
- `flutter test`
- Release build installed and checked on a connected iPhone

### Screenshots
A real-device Activity baseline showing the original solid footer is
attached in a PR comment. The updated review build was checked on the
connected iPhone.

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
2026-07-29 16:10:51 +01:00

667 lines
22 KiB
Dart

import 'dart:async';
import 'package:buzz/features/activity/activity_page.dart';
import 'package:buzz/features/activity/activity_provider.dart';
import 'package:buzz/features/activity/feed_item.dart';
import 'package:buzz/features/activity/inbox_item.dart';
import 'package:buzz/features/activity/reminders_provider.dart';
import 'package:buzz/features/channels/channel.dart';
import 'package:buzz/features/channels/channel_detail_page.dart';
import 'package:buzz/features/channels/message_content.dart';
import 'package:buzz/features/channels/channels_provider.dart';
import 'package:buzz/features/channels/read_state/read_state_provider.dart';
import 'package:buzz/features/profile/user_cache_provider.dart';
import 'package:buzz/features/profile/user_profile.dart';
import 'package:buzz/shared/theme/theme.dart';
import 'package:buzz/shared/widgets/frosted_app_bar.dart';
import 'package:buzz/shared/widgets/avatar_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
void main() {
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
final testMention = FeedItem(
id: 'm1',
kind: 9,
pubkey: 'alice_pk',
content: 'Hey check this out',
createdAt: now - 120,
channelId: 'ch1',
channelName: 'general',
tags: const [],
category: 'mention',
);
final testThreadReply = FeedItem(
id: 'a1',
kind: 9,
pubkey: 'bob_pk',
content: 'Deployed the fix',
createdAt: now - 3600,
channelId: 'ch2',
channelName: 'engineering',
tags: const [
['e', 'root1', '', 'root'],
['e', 'root1', '', 'reply'],
],
category: 'activity',
);
final testAgent = FeedItem(
id: 'ag1',
kind: 43004,
pubkey: 'agent_pk',
content: 'Job completed successfully',
createdAt: now - 60,
channelId: 'ch1',
channelName: 'general',
tags: const [],
category: 'agent_activity',
);
final testFeed = HomeFeedResponse(
mentions: [testMention],
needsAction: const [],
activity: [testThreadReply],
agentActivity: [testAgent],
);
final testChannels = [
Channel(
id: 'ch1',
name: 'general',
channelType: 'stream',
visibility: 'open',
description: '',
createdBy: 'x',
createdAt: DateTime(2025),
memberCount: 5,
isMember: true,
),
Channel(
id: 'ch2',
name: 'engineering',
channelType: 'stream',
visibility: 'open',
description: '',
createdBy: 'x',
createdAt: DateTime(2025),
memberCount: 3,
isMember: true,
),
];
final testUsers = <String, UserProfile>{
'alice_pk': const UserProfile(
pubkey: 'alice_pk',
displayName: 'Alice',
nip05Handle: 'alice@example.com',
),
'bob_pk': const UserProfile(pubkey: 'bob_pk', displayName: 'Bob'),
'agent_pk': const UserProfile(pubkey: 'agent_pk', displayName: 'Scout'),
};
Future<Widget> buildTestable({
HomeFeedResponse? feed,
ActivityNotifier Function()? activityNotifier,
Map<String, UserProfile>? users,
Map<String, int> readContexts = const {},
List<Channel>? channels,
TextScaler? textScaler,
}) async {
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
return ProviderScope(
overrides: [
savedPrefsProvider.overrideWithValue(prefs),
activityProvider.overrideWith(
activityNotifier ?? () => _FakeActivityNotifier(feed ?? testFeed),
),
channelsProvider.overrideWith(
() => _FakeChannelsNotifier(channels ?? testChannels),
),
userCacheProvider.overrideWith(
() => _FakeUserCacheNotifier(users ?? testUsers),
),
readStateProvider.overrideWith(
() => _FakeReadStateNotifier(readContexts),
),
remindersProvider.overrideWith(() => _FakeRemindersNotifier(const [])),
],
child: MaterialApp(
theme: AppTheme.light(),
builder: textScaler == null
? null
: (context, child) => MediaQuery(
data: MediaQuery.of(context).copyWith(textScaler: textScaler),
child: child!,
),
home: const ActivityPage(),
),
);
}
testWidgets('shows loading skeleton while feed loads', (tester) async {
await tester.pumpWidget(
await buildTestable(activityNotifier: _PendingActivityNotifier.new),
);
// Single pump - don't settle, the future never completes.
await tester.pump();
expect(find.byType(Container), findsWidgets);
expect(find.text('Hey check this out'), findsNothing);
});
testWidgets('shows empty state when feed is empty', (tester) async {
await tester.pumpWidget(
await buildTestable(
feed: HomeFeedResponse(
mentions: const [],
needsAction: const [],
activity: const [],
agentActivity: const [],
),
),
);
await tester.pumpAndSettle();
expect(find.text('No activity yet'), findsOneWidget);
});
testWidgets('does not imply a back button for the top-level Activity tab', (
tester,
) async {
await tester.pumpWidget(await buildTestable());
await tester.pumpAndSettle();
final appBar = tester.widget<FrostedAppBar>(find.byType(FrostedAppBar));
expect(appBar.automaticallyImplyLeading, isFalse);
expect(find.byTooltip('Back'), findsNothing);
});
testWidgets('keeps bottom clearance for the floating tab bar', (
tester,
) async {
await tester.pumpWidget(await buildTestable());
await tester.pumpAndSettle();
final safeAreas = tester.widgetList<SafeArea>(find.byType(SafeArea));
expect(safeAreas, hasLength(1));
expect(safeAreas.single.top, isFalse);
expect(safeAreas.single.bottom, isTrue);
});
testWidgets('shows error view with retry button', (tester) async {
await tester.pumpWidget(
await buildTestable(activityNotifier: _ErrorActivityNotifier.new),
);
await tester.pumpAndSettle();
expect(find.text('Failed to load activity'), findsOneWidget);
expect(find.text('Retry'), findsOneWidget);
});
testWidgets('activity popovers use fixed-layout scale and opacity motion', (
tester,
) async {
await tester.pumpWidget(await buildTestable());
await tester.pumpAndSettle();
final filterTrigger = find.byKey(const ValueKey('activity-filter-menu'));
expect(
tester.getSize(filterTrigger).height,
greaterThanOrEqualTo(Grid.xl),
reason: 'The Activity filter trigger must keep a 48dp touch target.',
);
await tester.tap(filterTrigger);
await tester.pump();
final surface = find.byKey(const ValueKey('activity-filter-popover'));
final fade = find.byKey(const ValueKey('activity-popover-fade'));
final scale = find.byKey(const ValueKey('activity-popover-scale'));
expect(surface, findsOneWidget);
expect(fade, findsOneWidget);
expect(scale, findsOneWidget);
final initialSize = tester.getSize(surface);
final initialFade = tester.widget<FadeTransition>(fade);
final initialScale = tester.widget<ScaleTransition>(scale);
expect(initialSize.width, 240);
expect(initialFade.opacity.value, lessThan(1));
expect(initialScale.scale.value, greaterThanOrEqualTo(0.96));
expect(initialScale.scale.value, lessThan(1));
expect(initialScale.alignment, Alignment.topLeft);
await tester.pump(const Duration(milliseconds: 75));
final movingFade = tester.widget<FadeTransition>(fade);
final movingScale = tester.widget<ScaleTransition>(scale);
expect(movingFade.opacity.value, greaterThan(0));
expect(movingFade.opacity.value, lessThan(1));
expect(movingScale.scale.value, greaterThan(0.96));
expect(movingScale.scale.value, lessThan(1));
expect(tester.getSize(surface), initialSize);
await tester.pump(const Duration(milliseconds: 75));
expect(tester.widget<FadeTransition>(fade).opacity.value, 1);
expect(tester.widget<ScaleTransition>(scale).scale.value, 1);
expect(tester.getSize(surface), initialSize);
final material = tester.widget<Material>(surface);
final shape = material.shape! as RoundedRectangleBorder;
expect(shape.borderRadius, BorderRadius.circular(Radii.card));
expect(material.surfaceTintColor, Colors.transparent);
expect(material.clipBehavior, Clip.antiAlias);
final items = tester.widgetList<PopupMenuItem<InboxFilter>>(
find.byType(PopupMenuItem<InboxFilter>),
);
expect(items, hasLength(InboxFilter.values.length));
expect(
items.every((item) => item.height >= Grid.xl),
isTrue,
reason: 'Activity filter choices must keep 48dp touch targets.',
);
await tester.tap(find.descendant(of: surface, matching: find.text('All')));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const ValueKey('activity-options-menu')));
await tester.pump();
final optionsSurface = find.byKey(
const ValueKey('activity-options-popover'),
);
expect(tester.getSize(optionsSurface).width, 216);
expect(
tester
.widget<ScaleTransition>(
find.byKey(const ValueKey('activity-popover-scale')),
)
.alignment,
Alignment.topRight,
);
});
testWidgets('rows lead with sender, contextual label, and preview', (
tester,
) async {
await tester.pumpWidget(await buildTestable());
await tester.pumpAndSettle();
// Sender names resolved from the user cache.
expect(find.text('Alice'), findsOneWidget);
expect(find.text('alice@example.com'), findsOneWidget);
expect(find.text('Bob'), findsOneWidget);
expect(find.text('Scout'), findsOneWidget);
// Contextual labels + channel chips.
expect(find.text('Mentioned in'), findsOneWidget);
expect(find.text('Thread in'), findsOneWidget);
expect(find.text('#general'), findsNWidgets(2)); // mention + agent
expect(find.text('#engineering'), findsOneWidget);
// Context labels and channel pills share the compact Activity style.
final contextLabel = tester.widget<Text>(find.text('Mentioned in'));
final channelLabel = tester.widgetList<Text>(find.text('#general')).first;
expect(contextLabel.style?.fontSize, activityContextTextStyle.fontSize);
expect(contextLabel.style?.fontWeight, activityContextTextStyle.fontWeight);
expect(contextLabel.style?.height, activityContextTextStyle.height);
expect(channelLabel.style?.fontSize, activityContextTextStyle.fontSize);
expect(channelLabel.style?.fontWeight, activityContextTextStyle.fontWeight);
expect(channelLabel.style?.height, activityContextTextStyle.height);
// Message previews.
expect(find.textContaining('Hey check this out'), findsOneWidget);
expect(find.textContaining('Deployed the fix'), findsOneWidget);
// Activity rows use their conversation-oriented scale.
final senderText = tester.widget<Text>(find.text('Alice'));
final theme = Theme.of(tester.element(find.text('Alice')));
expect(senderText.style?.fontSize, activityUsernameTextStyle.fontSize);
expect(senderText.style?.fontWeight, activityUsernameTextStyle.fontWeight);
expect(senderText.style?.height, activityUsernameTextStyle.height);
expect(senderText.style?.color, theme.colorScheme.onSurface);
final usernameText = tester.widget<Text>(
find.byKey(const ValueKey('activity-username-m1')),
);
final timestampText = tester.widget<Text>(
find.byKey(const ValueKey('activity-timestamp-m1')),
);
expect(usernameText.style?.fontSize, messageMetadataTextStyle.fontSize);
expect(usernameText.style?.fontWeight, FontWeight.w400);
expect(usernameText.style?.height, messageMetadataTextStyle.height);
expect(timestampText.style?.fontSize, messageMetadataTextStyle.fontSize);
expect(timestampText.style?.fontWeight, FontWeight.w400);
final avatars = tester.widgetList<AvatarImage>(find.byType(AvatarImage));
expect(avatars, isNotEmpty);
expect(
avatars.every((avatar) => avatar.radius == activityAvatarSize / 2),
isTrue,
);
final previews = tester.widgetList<MessageContent>(
find.byType(MessageContent),
);
expect(previews, isNotEmpty);
expect(
previews.every(
(preview) =>
preview.baseStyle?.fontSize == activityPreviewTextStyle.fontSize &&
preview.baseStyle?.fontWeight ==
activityPreviewTextStyle.fontWeight &&
preview.baseStyle?.height == activityPreviewTextStyle.height &&
preview.baseStyle?.letterSpacing ==
activityPreviewTextStyle.letterSpacing &&
preview.baseStyle?.color == theme.colorScheme.onSurface,
),
isTrue,
);
});
testWidgets('multiple top-level messages in one DM render one row', (
tester,
) async {
final dmChannel = Channel(
id: 'dm1',
name: 'dm',
channelType: 'dm',
visibility: 'private',
description: '',
createdBy: 'x',
createdAt: DateTime(2025),
memberCount: 2,
isMember: true,
participants: const ['Alice'],
);
FeedItem dmMessage(String id, int age) => FeedItem(
id: id,
kind: 9,
pubkey: 'alice_pk',
content: 'dm body $id',
createdAt: now - age,
channelId: 'dm1',
channelName: '',
tags: const [],
category: 'activity',
);
await tester.pumpWidget(
await buildTestable(
feed: HomeFeedResponse(
mentions: const [],
needsAction: const [],
activity: [dmMessage('dm-a', 300), dmMessage('dm-b', 200)],
agentActivity: const [],
),
channels: [...testChannels, dmChannel],
),
);
await tester.pumpAndSettle();
// One conversation row for the DM, represented by the latest message.
expect(find.byKey(const ValueKey('inbox-row-dm-b')), findsOneWidget);
expect(find.byKey(const ValueKey('inbox-row-dm-a')), findsNothing);
expect(find.textContaining('dm body dm-b'), findsOneWidget);
expect(find.textContaining('dm body dm-a'), findsNothing);
});
testWidgets('unread rows show a dot; read rows do not', (tester) async {
await tester.pumpWidget(
await buildTestable(
// ch1 fully read; ch2 (thread) unread.
readContexts: {'ch1': now},
),
);
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('inbox-unread-dot-m1')), findsNothing);
expect(find.byKey(const ValueKey('inbox-unread-dot-ag1')), findsNothing);
expect(find.byKey(const ValueKey('inbox-unread-dot-a1')), findsOneWidget);
});
testWidgets('New boundary separates unread rows from read rows', (
tester,
) async {
await tester.pumpWidget(
await buildTestable(
// Mention (2m ago) and agent (1m ago) unread; thread (1h ago) read.
readContexts: {'thread:root1': now, 'ch2': now},
),
);
await tester.pumpAndSettle();
expect(find.text('New'), findsOneWidget);
});
testWidgets('filter menu switches sources and shows empty states', (
tester,
) async {
await tester.pumpWidget(await buildTestable());
await tester.pumpAndSettle();
await tester.tap(find.byKey(const ValueKey('activity-filter-menu')));
await tester.pumpAndSettle();
await tester.tap(find.text('Mentions'));
await tester.pumpAndSettle();
expect(find.textContaining('Hey check this out'), findsOneWidget);
expect(find.textContaining('Deployed the fix'), findsNothing);
expect(find.textContaining('Job completed successfully'), findsNothing);
await tester.tap(find.byKey(const ValueKey('activity-filter-menu')));
await tester.pumpAndSettle();
await tester.tap(find.text('Needs Action'));
await tester.pumpAndSettle();
expect(find.text('Nothing needs your action'), findsOneWidget);
});
testWidgets('filter menu supports accessibility text scaling', (
tester,
) async {
await tester.pumpWidget(
await buildTestable(textScaler: const TextScaler.linear(3)),
);
await tester.pumpAndSettle();
await tester.tap(find.byKey(const ValueKey('activity-filter-menu')));
await tester.pumpAndSettle();
expect(
find.byKey(const ValueKey('activity-filter-popover')),
findsOneWidget,
);
expect(tester.takeException(), isNull);
});
testWidgets('opens a thread mention at the referenced message', (
tester,
) async {
final threadMention = FeedItem(
id: 'reply-event',
kind: 9,
pubkey: 'alice_pk',
content: 'Reply in a thread',
createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000,
channelId: 'ch1',
channelName: 'general',
tags: const [
['e', 'thread-root', '', 'root'],
['e', 'parent-reply', '', 'reply'],
],
category: 'mention',
);
final feed = HomeFeedResponse(
mentions: [threadMention],
needsAction: const [],
activity: const [],
agentActivity: const [],
);
await tester.pumpWidget(await buildTestable(feed: feed));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const ValueKey('inbox-row-reply-event')));
await tester.pumpAndSettle();
final page = tester.widget<ChannelDetailPage>(
find.byType(ChannelDetailPage),
);
expect(page.channel.id, 'ch1');
expect(page.initialThreadRootId, 'parent-reply');
expect(page.initialMessageId, 'reply-event');
});
testWidgets('thread filter matches grouped thread replies', (tester) async {
await tester.pumpWidget(await buildTestable());
await tester.pumpAndSettle();
await tester.tap(find.byKey(const ValueKey('activity-filter-menu')));
await tester.pumpAndSettle();
await tester.tap(find.text('Threads'));
await tester.pumpAndSettle();
expect(find.textContaining('Deployed the fix'), findsOneWidget);
expect(find.textContaining('Hey check this out'), findsNothing);
});
testWidgets('reminders filter shows the reminders empty surface', (
tester,
) async {
await tester.pumpWidget(await buildTestable());
await tester.pumpAndSettle();
await tester.tap(find.byKey(const ValueKey('activity-filter-menu')));
await tester.pumpAndSettle();
await tester.tap(find.text('Reminders'));
await tester.pumpAndSettle();
expect(find.text('No reminders'), findsOneWidget);
});
testWidgets('drafts filter shows the drafts empty surface', (tester) async {
await tester.pumpWidget(await buildTestable());
await tester.pumpAndSettle();
await tester.tap(find.byKey(const ValueKey('activity-filter-menu')));
await tester.pumpAndSettle();
await tester.tap(find.text('Drafts'));
await tester.pumpAndSettle();
expect(find.text('No drafts'), findsOneWidget);
});
testWidgets('unread-only toggle hides read rows', (tester) async {
await tester.pumpWidget(await buildTestable(readContexts: {'ch1': now}));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const ValueKey('activity-options-menu')));
await tester.pumpAndSettle();
await tester.tap(find.text('Show unread'));
await tester.pumpAndSettle();
// Only the unread thread row remains.
expect(find.textContaining('Deployed the fix'), findsOneWidget);
expect(find.textContaining('Hey check this out'), findsNothing);
expect(find.textContaining('Job completed successfully'), findsNothing);
});
testWidgets('long-press mark unread reopens a read row', (tester) async {
await tester.pumpWidget(
await buildTestable(
readContexts: {'ch1': now, 'ch2': now, 'thread:root1': now},
),
);
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('inbox-unread-dot-m1')), findsNothing);
await tester.longPress(find.byKey(const ValueKey('inbox-row-m1')));
await tester.pumpAndSettle();
await tester.tap(find.text('Mark unread'));
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('inbox-unread-dot-m1')), findsOneWidget);
});
testWidgets('falls back to short pubkey when user not cached', (
tester,
) async {
await tester.pumpWidget(await buildTestable(users: const {}));
await tester.pumpAndSettle();
// Sender label falls back to the (short) pubkey.
expect(find.text('alice_pk'), findsOneWidget);
expect(find.text('Alice'), findsNothing);
});
}
class _FakeActivityNotifier extends ActivityNotifier {
final HomeFeedResponse _feed;
_FakeActivityNotifier(this._feed);
@override
Future<HomeFeedResponse> build() async => _feed;
}
class _PendingActivityNotifier extends ActivityNotifier {
@override
Future<HomeFeedResponse> build() => Completer<HomeFeedResponse>().future;
}
class _ErrorActivityNotifier extends ActivityNotifier {
@override
Future<HomeFeedResponse> build() => Future.error('Connection refused');
}
class _FakeChannelsNotifier extends ChannelsNotifier {
final List<Channel> _channels;
_FakeChannelsNotifier(this._channels);
@override
Future<List<Channel>> build() async => _channels;
}
class _FakeUserCacheNotifier extends UserCacheNotifier {
final Map<String, UserProfile> _users;
_FakeUserCacheNotifier(this._users);
@override
Map<String, UserProfile> build() => _users;
}
class _FakeReadStateNotifier extends ReadStateNotifier {
final Map<String, int> _contexts;
_FakeReadStateNotifier(this._contexts);
@override
ReadStateState build() => ReadStateState(
isReady: true,
pubkey: 'me_pk',
contexts: Map.unmodifiable(_contexts),
version: 1,
);
@override
void markContextRead(
String contextId,
int unixTimestamp, {
bool clearForcedMessages = false,
}) {
state = state.copyWithContext(contextId, unixTimestamp);
}
}
class _FakeRemindersNotifier extends RemindersNotifier {
final List<Reminder> _reminders;
_FakeRemindersNotifier(this._reminders);
@override
Future<List<Reminder>> build() async => _reminders;
}