mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
491 lines
15 KiB
Dart
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'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|