feat(mobile): implement Activity tab with personalized feed (#337)

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Wes
2026-04-16 10:02:29 -06:00
committed by GitHub
parent a43c6df878
commit 7cc9104ef2
5 changed files with 1203 additions and 21 deletions

View File

@@ -1,42 +1,489 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../shared/theme/theme.dart';
import '../channels/channel.dart';
import '../channels/channel_detail_page.dart';
import '../channels/channels_provider.dart';
import '../profile/user_cache_provider.dart';
import 'activity_provider.dart';
import 'feed_item.dart';
class ActivityPage extends StatelessWidget {
enum _Filter { all, mentions, needsAction, activity, agents }
class ActivityPage extends HookConsumerWidget {
const ActivityPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Activity')),
body: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
LucideIcons.bell,
size: Grid.xl,
color: context.colors.outline,
Widget build(BuildContext context, WidgetRef ref) {
final feedAsync = ref.watch(activityProvider);
final channelsAsync = ref.watch(channelsProvider);
final filter = useState(_Filter.all);
// Cache last successful feed so the UI doesn't flash on rebuild.
final cachedFeed = useRef<HomeFeedResponse?>(null);
if (feedAsync.asData?.value case final data?) {
cachedFeed.value = data;
}
final feed = cachedFeed.value;
final Widget body;
if (feed != null && !feed.isEmpty) {
final items = _filteredItems(feed, filter.value);
final channels = channelsAsync.asData?.value ?? [];
// Preload user profiles for visible feed items.
final pubkeys = items.map((i) => i.pubkey).toSet().toList();
ref.read(userCacheProvider.notifier).preload(pubkeys);
body = Column(
children: [
_FilterBar(
selected: filter.value,
onSelected: (f) => filter.value = f,
counts: _FilterCounts(
mentions: feed.mentions.length,
needsAction: feed.needsAction.length,
activity: feed.activity.length,
agents: feed.agentActivity.length,
),
const SizedBox(height: Grid.xs),
Text(
'No activity yet',
style: context.textTheme.bodyLarge?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
Expanded(
child: items.isEmpty
? _EmptyFilterState(filter: filter.value)
: RefreshIndicator(
onRefresh: () =>
ref.read(activityProvider.notifier).refresh(),
child: ListView.separated(
padding: const EdgeInsets.symmetric(vertical: Grid.xxs),
itemCount: items.length,
separatorBuilder: (_, _) => const Divider(height: 1),
itemBuilder: (context, index) {
final item = items[index];
return _FeedItemTile(
item: item,
onTap: () => _openItem(context, item, channels),
);
},
),
),
),
],
);
} else if (feedAsync.hasError) {
body = _ErrorView(
onRetry: () => ref.read(activityProvider.notifier).refresh(),
);
} else if (feedAsync.hasValue) {
body = const _EmptyState();
} else {
body = const _LoadingSkeleton();
}
return Scaffold(body: SafeArea(child: body));
}
List<FeedItem> _filteredItems(HomeFeedResponse feed, _Filter filter) {
return switch (filter) {
_Filter.all => feed.all,
_Filter.mentions => feed.mentions,
_Filter.needsAction => feed.needsAction,
_Filter.activity => feed.activity,
_Filter.agents => feed.agentActivity,
};
}
void _openItem(BuildContext context, FeedItem item, List<Channel> channels) {
if (item.channelId == null) return;
final channel = channels
.where((c) => c.id == item.channelId)
.cast<Channel?>()
.firstOrNull;
if (channel == null) return;
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => ChannelDetailPage(channel: channel),
),
);
}
}
// ---------------------------------------------------------------------------
// Filter bar
// ---------------------------------------------------------------------------
class _FilterCounts {
final int mentions;
final int needsAction;
final int activity;
final int agents;
const _FilterCounts({
required this.mentions,
required this.needsAction,
required this.activity,
required this.agents,
});
}
class _FilterBar extends StatelessWidget {
final _Filter selected;
final ValueChanged<_Filter> onSelected;
final _FilterCounts counts;
const _FilterBar({
required this.selected,
required this.onSelected,
required this.counts,
});
@override
Widget build(BuildContext context) {
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(
horizontal: Grid.xs,
vertical: Grid.xxs,
),
child: Row(
children: [
_chip(context, _Filter.all, 'All', null, null),
const SizedBox(width: Grid.xxs),
_chip(
context,
_Filter.mentions,
'Mentions',
LucideIcons.atSign,
counts.mentions,
),
const SizedBox(width: Grid.xxs),
_chip(
context,
_Filter.needsAction,
'Action',
LucideIcons.circleAlert,
counts.needsAction,
),
const SizedBox(width: Grid.xxs),
_chip(
context,
_Filter.activity,
'Activity',
LucideIcons.activity,
counts.activity,
),
const SizedBox(width: Grid.xxs),
_chip(
context,
_Filter.agents,
'Agents',
LucideIcons.bot,
counts.agents,
),
],
),
);
}
Widget _chip(
BuildContext context,
_Filter filter,
String label,
IconData? icon,
int? count,
) {
final isSelected = selected == filter;
final text = count != null ? '$label ($count)' : label;
return FilterChip(
selected: isSelected,
showCheckmark: false,
label: icon != null
? Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 14),
const SizedBox(width: Grid.half),
Text(text, style: context.textTheme.labelSmall),
],
)
: Text(text, style: context.textTheme.labelSmall),
onSelected: (_) => onSelected(filter),
visualDensity: VisualDensity.compact,
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
);
}
}
// ---------------------------------------------------------------------------
// Feed item tile
// ---------------------------------------------------------------------------
class _FeedItemTile extends ConsumerWidget {
final FeedItem item;
final VoidCallback onTap;
const _FeedItemTile({required this.item, required this.onTap});
@override
Widget build(BuildContext context, WidgetRef ref) {
final profile = ref.watch(
userCacheProvider.select((cache) => cache[item.pubkey.toLowerCase()]),
);
final authorLabel = profile?.label ?? _shortPubkey(item.pubkey);
return InkWell(
onTap: item.channelId != null ? onTap : null,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: Grid.xs,
vertical: Grid.twelve,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header row: icon + headline + author + channel + time
Row(
children: [
Expanded(
child: Row(
children: [
Icon(
_categoryIcon(item.category),
size: 14,
color: context.colors.primary,
),
const SizedBox(width: Grid.half),
Text(
item.headline,
style: context.textTheme.labelMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(width: Grid.xxs),
Flexible(
child: Text(
authorLabel,
style: context.textTheme.labelSmall?.copyWith(
color: context.colors.onSurfaceVariant,
),
overflow: TextOverflow.ellipsis,
),
),
if (item.channelName.isNotEmpty) ...[
const SizedBox(width: Grid.half),
Flexible(
child: Text(
'#${item.channelName}',
style: context.textTheme.labelSmall?.copyWith(
color: context.colors.primary.withValues(
alpha: 0.8,
),
),
overflow: TextOverflow.ellipsis,
),
),
],
],
),
),
const SizedBox(width: Grid.xxs),
Text(
_relativeTime(item.createdAt),
style: context.textTheme.labelSmall?.copyWith(
color: context.colors.outline,
),
),
],
),
const SizedBox(height: Grid.half),
// Content preview (max 2 lines)
Text(
'Mentions, replies, and reactions will show up here.',
item.displayContent,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: context.textTheme.bodySmall?.copyWith(
color: context.colors.outline,
color: context.colors.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
],
),
),
);
}
static IconData _categoryIcon(String category) {
return switch (category) {
'mention' => LucideIcons.atSign,
'needs_action' => LucideIcons.circleAlert,
'agent_activity' => LucideIcons.bot,
_ => LucideIcons.activity,
};
}
static String _shortPubkey(String pk) =>
pk.length >= 8 ? '${pk.substring(0, 8)}...' : pk;
static String _relativeTime(int unixSeconds) {
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
final diff = now - unixSeconds;
if (diff < 60) return 'now';
if (diff < 3600) return '${diff ~/ 60}m';
if (diff < 86400) return '${diff ~/ 3600}h';
if (diff < 604800) return '${diff ~/ 86400}d';
final date = DateTime.fromMillisecondsSinceEpoch(unixSeconds * 1000);
return '${date.month}/${date.day}';
}
}
// ---------------------------------------------------------------------------
// States: loading, empty, error
// ---------------------------------------------------------------------------
class _LoadingSkeleton extends StatelessWidget {
const _LoadingSkeleton();
@override
Widget build(BuildContext context) {
return ListView.separated(
padding: const EdgeInsets.all(Grid.xs),
itemCount: 8,
separatorBuilder: (_, _) => const SizedBox(height: Grid.xs),
itemBuilder: (context, _) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 180,
height: 12,
decoration: BoxDecoration(
color: context.colors.outlineVariant.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(4),
),
),
const SizedBox(height: Grid.xxs),
Container(
width: double.infinity,
height: 10,
decoration: BoxDecoration(
color: context.colors.outlineVariant.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(4),
),
),
const SizedBox(height: Grid.half),
Container(
width: 240,
height: 10,
decoration: BoxDecoration(
color: context.colors.outlineVariant.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(4),
),
),
],
),
);
}
}
class _EmptyState extends StatelessWidget {
const _EmptyState();
@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(LucideIcons.bell, size: Grid.xl, color: context.colors.outline),
const SizedBox(height: Grid.xs),
Text(
'No activity yet',
style: context.textTheme.bodyLarge?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
const SizedBox(height: Grid.half),
Text(
'Mentions, replies, and reactions will show up here.',
style: context.textTheme.bodySmall?.copyWith(
color: context.colors.outline,
),
textAlign: TextAlign.center,
),
],
),
);
}
}
class _EmptyFilterState extends StatelessWidget {
final _Filter filter;
const _EmptyFilterState({required this.filter});
@override
Widget build(BuildContext context) {
final (icon, message) = switch (filter) {
_Filter.mentions => (LucideIcons.atSign, 'No mentions yet'),
_Filter.needsAction => (
LucideIcons.circleAlert,
'Nothing needs your action',
),
_Filter.activity => (LucideIcons.activity, 'No recent channel activity'),
_Filter.agents => (LucideIcons.bot, 'No agent updates'),
_Filter.all => (LucideIcons.bell, 'No activity yet'),
};
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: Grid.lg, color: context.colors.outline),
const SizedBox(height: Grid.xxs),
Text(
message,
style: context.textTheme.bodyMedium?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
],
),
);
}
}
class _ErrorView extends StatelessWidget {
final VoidCallback onRetry;
const _ErrorView({required this.onRetry});
@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
LucideIcons.triangleAlert,
size: Grid.lg,
color: context.colors.error,
),
const SizedBox(height: Grid.xxs),
Text(
'Failed to load activity',
style: context.textTheme.bodyMedium?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
const SizedBox(height: Grid.xs),
FilledButton.icon(
onPressed: onRetry,
icon: const Icon(LucideIcons.refreshCcw, size: 16),
label: const Text('Retry'),
),
],
),
);
}
}

View File

@@ -0,0 +1,37 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../shared/relay/relay.dart';
import 'feed_item.dart';
class ActivityNotifier extends AsyncNotifier<HomeFeedResponse> {
@override
Future<HomeFeedResponse> build() {
ref.watch(relayClientProvider);
// Re-fetch when websocket reconnects so feed stays fresh.
ref.watch(relaySessionProvider);
return _fetch();
}
Future<HomeFeedResponse> _fetch() async {
final client = ref.read(relayClientProvider);
final json =
await client.get(
'/api/feed',
queryParams: {
'limit': '20',
'types': 'mentions,needs_action,activity,agent_activity',
},
)
as Map<String, dynamic>;
return HomeFeedResponse.fromJson(json);
}
Future<void> refresh() async {
state = await AsyncValue.guard(_fetch);
}
}
final activityProvider =
AsyncNotifierProvider<ActivityNotifier, HomeFeedResponse>(
ActivityNotifier.new,
);

View File

@@ -0,0 +1,127 @@
import 'package:flutter/foundation.dart';
/// A single item from the home activity feed.
@immutable
class FeedItem {
final String id;
final int kind;
final String pubkey;
final String content;
final int createdAt;
final String? channelId;
final String channelName;
final List<List<String>> tags;
final String
category; // "mention", "needs_action", "activity", "agent_activity"
const FeedItem({
required this.id,
required this.kind,
required this.pubkey,
required this.content,
required this.createdAt,
required this.channelId,
required this.channelName,
required this.tags,
required this.category,
});
factory FeedItem.fromJson(Map<String, dynamic> json) => FeedItem(
id: json['id'] as String,
kind: json['kind'] as int,
pubkey: json['pubkey'] as String,
content: json['content'] as String,
createdAt: json['created_at'] as int,
channelId: json['channel_id'] as String?,
channelName: (json['channel_name'] as String?) ?? '',
tags:
(json['tags'] as List<dynamic>?)
?.map((t) => (t as List<dynamic>).map((e) => e as String).toList())
.toList() ??
const [],
category: json['category'] as String,
);
/// Human-readable headline based on event kind and category.
String get headline {
switch (kind) {
case 45001:
return 'Forum post';
case 45003:
return 'Forum reply';
case 46010:
return 'Approval requested';
case 43001:
return 'Job requested';
case 43002:
return 'Job accepted';
case 43003:
return 'Progress update';
case 43004:
return 'Job result';
case 43005:
return 'Job cancelled';
case 43006:
return 'Job failed';
default:
if (category == 'mention') return 'Mention';
if (category == 'agent_activity') return 'Agent update';
return 'Channel update';
}
}
/// Trimmed content, with a fallback for empty events.
String get displayContent {
final trimmed = content.trim();
if (trimmed.isNotEmpty) return trimmed;
if (kind == 46010) return 'A workflow is waiting for approval.';
return 'No additional details.';
}
}
/// Parsed response from GET /api/feed.
@immutable
class HomeFeedResponse {
final List<FeedItem> mentions;
final List<FeedItem> needsAction;
final List<FeedItem> activity;
final List<FeedItem> agentActivity;
HomeFeedResponse({
required this.mentions,
required this.needsAction,
required this.activity,
required this.agentActivity,
});
factory HomeFeedResponse.fromJson(Map<String, dynamic> json) {
final feed = json['feed'] as Map<String, dynamic>;
return HomeFeedResponse(
mentions: _parseItems(feed['mentions']),
needsAction: _parseItems(feed['needs_action']),
activity: _parseItems(feed['activity']),
agentActivity: _parseItems(feed['agent_activity']),
);
}
/// All items merged into a single list, sorted newest-first.
late final List<FeedItem> all = () {
final items = [...mentions, ...needsAction, ...activity, ...agentActivity];
items.sort((a, b) => b.createdAt.compareTo(a.createdAt));
return List<FeedItem>.unmodifiable(items);
}();
bool get isEmpty =>
mentions.isEmpty &&
needsAction.isEmpty &&
activity.isEmpty &&
agentActivity.isEmpty;
}
List<FeedItem> _parseItems(dynamic json) {
if (json == null) return const [];
return (json as List<dynamic>)
.cast<Map<String, dynamic>>()
.map(FeedItem.fromJson)
.toList();
}

View File

@@ -0,0 +1,310 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:hooks_riverpod/misc.dart';
import 'package:sprout_mobile/features/activity/activity_page.dart';
import 'package:sprout_mobile/features/activity/activity_provider.dart';
import 'package:sprout_mobile/features/activity/feed_item.dart';
import 'package:sprout_mobile/features/channels/channel.dart';
import 'package:sprout_mobile/features/channels/channels_provider.dart';
import 'package:sprout_mobile/features/profile/user_cache_provider.dart';
import 'package:sprout_mobile/features/profile/user_profile.dart';
import 'package:sprout_mobile/shared/theme/theme.dart';
void main() {
Widget buildTestable({required List<Override> overrides}) {
return ProviderScope(
overrides: overrides,
child: MaterialApp(
theme: AppTheme.lightTheme,
home: const ActivityPage(),
),
);
}
final testMention = FeedItem(
id: 'm1',
kind: 9,
pubkey: 'alice_pk',
content: 'Hey check this out',
createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000 - 120,
channelId: 'ch1',
channelName: 'general',
tags: const [],
category: 'mention',
);
final testActivity = FeedItem(
id: 'a1',
kind: 9,
pubkey: 'bob_pk',
content: 'Deployed the fix',
createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000 - 3600,
channelId: 'ch2',
channelName: 'engineering',
tags: const [],
category: 'activity',
);
final testAgent = FeedItem(
id: 'ag1',
kind: 43004,
pubkey: 'agent_pk',
content: 'Job completed successfully',
createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000 - 60,
channelId: 'ch1',
channelName: 'general',
tags: const [],
category: 'agent_activity',
);
final testFeed = HomeFeedResponse(
mentions: [testMention],
needsAction: const [],
activity: [testActivity],
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'),
'bob_pk': const UserProfile(pubkey: 'bob_pk', displayName: 'Bob'),
'agent_pk': const UserProfile(pubkey: 'agent_pk', displayName: 'Scout'),
};
final emptyUsers = <String, UserProfile>{};
List<Override> defaultOverrides({HomeFeedResponse? feed}) => [
activityProvider.overrideWith(
() => _FakeActivityNotifier(feed ?? testFeed),
),
channelsProvider.overrideWith(() => _FakeChannelsNotifier(testChannels)),
userCacheProvider.overrideWith(() => _FakeUserCacheNotifier(testUsers)),
];
testWidgets('shows loading skeleton while feed loads', (tester) async {
await tester.pumpWidget(
buildTestable(
overrides: [
activityProvider.overrideWith(() => _PendingActivityNotifier()),
channelsProvider.overrideWith(() => _FakeChannelsNotifier([])),
userCacheProvider.overrideWith(
() => _FakeUserCacheNotifier(emptyUsers),
),
],
),
);
// Single pump - don't settle, the future never completes.
await tester.pump();
// Skeleton containers should be present (loading state).
expect(find.byType(Container), findsWidgets);
// No feed content visible.
expect(find.text('Mention'), findsNothing);
});
testWidgets('shows empty state when feed is empty', (tester) async {
final emptyFeed = HomeFeedResponse(
mentions: [],
needsAction: [],
activity: [],
agentActivity: [],
);
await tester.pumpWidget(
buildTestable(overrides: defaultOverrides(feed: emptyFeed)),
);
await tester.pumpAndSettle();
expect(find.text('No activity yet'), findsOneWidget);
expect(
find.text('Mentions, replies, and reactions will show up here.'),
findsOneWidget,
);
});
testWidgets('shows error view with retry button', (tester) async {
await tester.pumpWidget(
buildTestable(
overrides: [
activityProvider.overrideWith(() => _ErrorActivityNotifier()),
channelsProvider.overrideWith(() => _FakeChannelsNotifier([])),
userCacheProvider.overrideWith(
() => _FakeUserCacheNotifier(emptyUsers),
),
],
),
);
await tester.pumpAndSettle();
expect(find.text('Failed to load activity'), findsOneWidget);
expect(find.text('Retry'), findsOneWidget);
});
testWidgets('shows feed items with correct content', (tester) async {
await tester.pumpWidget(buildTestable(overrides: defaultOverrides()));
await tester.pumpAndSettle();
// All three items visible in "All" filter.
expect(find.text('Hey check this out'), findsOneWidget);
expect(find.text('Deployed the fix'), findsOneWidget);
expect(find.text('Job completed successfully'), findsOneWidget);
// Author names resolved from user cache.
expect(find.text('Alice'), findsOneWidget);
expect(find.text('Bob'), findsOneWidget);
expect(find.text('Scout'), findsOneWidget);
// Channel names visible.
expect(find.text('#general'), findsNWidgets(2)); // mention + agent
expect(find.text('#engineering'), findsOneWidget);
});
testWidgets('shows filter chips with counts', (tester) async {
await tester.pumpWidget(buildTestable(overrides: defaultOverrides()));
await tester.pumpAndSettle();
expect(find.text('All'), findsOneWidget);
expect(find.text('Mentions (1)'), findsOneWidget);
expect(find.text('Action (0)'), findsOneWidget);
expect(find.text('Activity (1)'), findsOneWidget);
expect(find.text('Agents (1)'), findsOneWidget);
});
testWidgets('filtering shows only matching items', (tester) async {
await tester.pumpWidget(buildTestable(overrides: defaultOverrides()));
await tester.pumpAndSettle();
// Tap the Mentions filter.
await tester.tap(find.text('Mentions (1)'));
await tester.pumpAndSettle();
expect(find.text('Hey check this out'), findsOneWidget);
expect(find.text('Deployed the fix'), findsNothing);
expect(find.text('Job completed successfully'), findsNothing);
});
testWidgets('empty filter shows per-filter empty state', (tester) async {
await tester.pumpWidget(buildTestable(overrides: defaultOverrides()));
await tester.pumpAndSettle();
// Tap Action filter (has 0 items).
await tester.tap(find.text('Action (0)'));
await tester.pumpAndSettle();
expect(find.text('Nothing needs your action'), findsOneWidget);
});
testWidgets('shows headline for known event kinds', (tester) async {
await tester.pumpWidget(buildTestable(overrides: defaultOverrides()));
await tester.pumpAndSettle();
// testAgent has kind 43004 -> "Job result"
expect(find.text('Job result'), findsOneWidget);
// testMention has kind 9, category mention -> "Mention"
expect(find.text('Mention'), findsOneWidget);
// testActivity has kind 9, category activity -> "Channel update"
expect(find.text('Channel update'), findsOneWidget);
});
testWidgets('falls back to short pubkey when user not cached', (
tester,
) async {
await tester.pumpWidget(
buildTestable(
overrides: [
activityProvider.overrideWith(() => _FakeActivityNotifier(testFeed)),
channelsProvider.overrideWith(
() => _FakeChannelsNotifier(testChannels),
),
// Empty user cache - no profiles resolved.
userCacheProvider.overrideWith(
() => _FakeUserCacheNotifier(emptyUsers),
),
],
),
);
await tester.pumpAndSettle();
// Should show truncated pubkey instead of display name.
expect(find.text('alice_pk...'), findsOneWidget);
expect(find.text('Alice'), findsNothing);
});
testWidgets('timestamps are right-aligned consistently', (tester) async {
await tester.pumpWidget(buildTestable(overrides: defaultOverrides()));
await tester.pumpAndSettle();
// Find all time labels - they should show relative times.
// testMention: 120s ago -> "2m"
expect(find.text('2m'), findsOneWidget);
// testActivity: 3600s ago -> "1h"
expect(find.text('1h'), findsOneWidget);
// testAgent: 60s ago -> "1m"
expect(find.text('1m'), findsOneWidget);
});
}
// ---------------------------------------------------------------------------
// Fake notifiers
// ---------------------------------------------------------------------------
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;
}

View File

@@ -0,0 +1,261 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:sprout_mobile/features/activity/feed_item.dart';
void main() {
group('FeedItem.fromJson', () {
test('parses all fields', () {
final item = FeedItem.fromJson({
'id': 'evt1',
'kind': 9,
'pubkey': 'abc123',
'content': 'Hello world',
'created_at': 1700000000,
'channel_id': 'ch1',
'channel_name': 'general',
'tags': [
['p', 'abc123'],
],
'category': 'mention',
});
expect(item.id, 'evt1');
expect(item.kind, 9);
expect(item.pubkey, 'abc123');
expect(item.content, 'Hello world');
expect(item.createdAt, 1700000000);
expect(item.channelId, 'ch1');
expect(item.channelName, 'general');
expect(item.tags, [
['p', 'abc123'],
]);
expect(item.category, 'mention');
});
test('handles null channel_id', () {
final item = FeedItem.fromJson({
'id': 'evt2',
'kind': 9,
'pubkey': 'abc',
'content': '',
'created_at': 0,
'channel_id': null,
'channel_name': null,
'tags': null,
'category': 'activity',
});
expect(item.channelId, isNull);
expect(item.channelName, '');
expect(item.tags, isEmpty);
});
});
group('FeedItem.headline', () {
FeedItem makeItem({required int kind, String category = 'activity'}) =>
FeedItem(
id: 'x',
kind: kind,
pubkey: 'pk',
content: '',
createdAt: 0,
channelId: null,
channelName: '',
tags: const [],
category: category,
);
test('returns known kind labels', () {
expect(makeItem(kind: 45001).headline, 'Forum post');
expect(makeItem(kind: 45003).headline, 'Forum reply');
expect(makeItem(kind: 46010).headline, 'Approval requested');
expect(makeItem(kind: 43001).headline, 'Job requested');
expect(makeItem(kind: 43002).headline, 'Job accepted');
expect(makeItem(kind: 43003).headline, 'Progress update');
expect(makeItem(kind: 43004).headline, 'Job result');
expect(makeItem(kind: 43005).headline, 'Job cancelled');
expect(makeItem(kind: 43006).headline, 'Job failed');
});
test('falls back to category for unknown kinds', () {
expect(makeItem(kind: 9, category: 'mention').headline, 'Mention');
expect(
makeItem(kind: 9, category: 'agent_activity').headline,
'Agent update',
);
expect(
makeItem(kind: 9, category: 'activity').headline,
'Channel update',
);
});
});
group('FeedItem.displayContent', () {
FeedItem makeItem({required String content, int kind = 9}) => FeedItem(
id: 'x',
kind: kind,
pubkey: 'pk',
content: content,
createdAt: 0,
channelId: null,
channelName: '',
tags: const [],
category: 'activity',
);
test('returns trimmed content when non-empty', () {
expect(makeItem(content: ' Hello ').displayContent, 'Hello');
});
test('returns approval fallback for empty approval events', () {
expect(
makeItem(content: '', kind: 46010).displayContent,
'A workflow is waiting for approval.',
);
});
test('returns generic fallback for other empty events', () {
expect(makeItem(content: '').displayContent, 'No additional details.');
expect(makeItem(content: ' ').displayContent, 'No additional details.');
});
});
group('HomeFeedResponse', () {
test('parses from JSON with all four categories', () {
final response = HomeFeedResponse.fromJson({
'feed': {
'mentions': [
{
'id': 'm1',
'kind': 9,
'pubkey': 'pk',
'content': 'hi',
'created_at': 100,
'channel_id': 'c1',
'channel_name': 'general',
'tags': [],
'category': 'mention',
},
],
'needs_action': [],
'activity': [
{
'id': 'a1',
'kind': 9,
'pubkey': 'pk',
'content': 'update',
'created_at': 200,
'channel_id': 'c2',
'channel_name': 'dev',
'tags': [],
'category': 'activity',
},
],
'agent_activity': [],
},
'meta': {'since': 0, 'total': 2, 'generated_at': 300},
});
expect(response.mentions.length, 1);
expect(response.needsAction, isEmpty);
expect(response.activity.length, 1);
expect(response.agentActivity, isEmpty);
});
test('handles null category lists gracefully', () {
final response = HomeFeedResponse.fromJson({
'feed': {
'mentions': null,
'needs_action': null,
'activity': null,
'agent_activity': null,
},
});
expect(response.mentions, isEmpty);
expect(response.isEmpty, isTrue);
});
test('all merges and sorts newest-first', () {
final response = HomeFeedResponse(
mentions: [
FeedItem(
id: 'old',
kind: 9,
pubkey: 'pk',
content: '',
createdAt: 100,
channelId: null,
channelName: '',
tags: const [],
category: 'mention',
),
],
needsAction: const [],
activity: [
FeedItem(
id: 'new',
kind: 9,
pubkey: 'pk',
content: '',
createdAt: 300,
channelId: null,
channelName: '',
tags: const [],
category: 'activity',
),
],
agentActivity: [
FeedItem(
id: 'mid',
kind: 9,
pubkey: 'pk',
content: '',
createdAt: 200,
channelId: null,
channelName: '',
tags: const [],
category: 'agent_activity',
),
],
);
final all = response.all;
expect(all.length, 3);
expect(all[0].id, 'new');
expect(all[1].id, 'mid');
expect(all[2].id, 'old');
});
test('isEmpty returns true when all lists empty', () {
final response = HomeFeedResponse(
mentions: [],
needsAction: [],
activity: [],
agentActivity: [],
);
expect(response.isEmpty, isTrue);
});
test('isEmpty returns false when any list has items', () {
final response = HomeFeedResponse(
mentions: [
FeedItem(
id: 'x',
kind: 9,
pubkey: 'pk',
content: '',
createdAt: 0,
channelId: null,
channelName: '',
tags: const [],
category: 'mention',
),
],
needsAction: const [],
activity: const [],
agentActivity: const [],
);
expect(response.isEmpty, isFalse);
});
});
}