Files
buzz/mobile/lib/features/activity/activity_page.dart
2026-04-17 11:25:56 -07:00

491 lines
15 KiB
Dart

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';
enum _Filter { all, mentions, needsAction, activity, agents }
class ActivityPage extends HookConsumerWidget {
const ActivityPage({super.key});
@override
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,
),
),
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 SizedBox(height: Grid.half),
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(
item.displayContent,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: context.textTheme.bodySmall?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
],
),
),
);
}
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'),
),
],
),
);
}
}