mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
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:
@@ -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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
37
mobile/lib/features/activity/activity_provider.dart
Normal file
37
mobile/lib/features/activity/activity_provider.dart
Normal 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,
|
||||
);
|
||||
127
mobile/lib/features/activity/feed_item.dart
Normal file
127
mobile/lib/features/activity/feed_item.dart
Normal 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();
|
||||
}
|
||||
310
mobile/test/features/activity/activity_page_test.dart
Normal file
310
mobile/test/features/activity/activity_page_test.dart
Normal 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;
|
||||
}
|
||||
261
mobile/test/features/activity/feed_item_test.dart
Normal file
261
mobile/test/features/activity/feed_item_test.dart
Normal 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user