58d4cfab4d
Shell: move Projects/News/Calendar into ShellRoute so the NavigationRail persists across all screens. Show all 6 nav destinations on tablet instead of 3+More overflow. Phone bottom nav unchanged. Chat: master-detail layout on tablet — conversations list (320px) with inline chat panel. Tapping a conversation updates state instead of pushing a new route. Knowledge: responsive grid (2 cols portrait, 3 cols landscape) with card layout showing icon, title, body preview, and tags. Fix snippet data — read json['snippet'] which the API actually sends. Bump snippet length from 120 to 200 chars. Tasks now show description as snippet. News: responsive grid with the same breakpoints. Larger snippet (5 lines) in grid mode via snippetMaxLines parameter. Briefing: centered reading column (maxWidth 700) on wide screens, matching the web UI layout. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
237 lines
7.7 KiB
Dart
237 lines
7.7 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:go_router/go_router.dart';
|
|
|
|
import '../../core/constants.dart';
|
|
import '../../data/models/briefing_feed.dart';
|
|
import '../../providers/api_client_provider.dart';
|
|
import '../../providers/news_provider.dart';
|
|
import '../../widgets/news_card.dart';
|
|
|
|
class NewsScreen extends ConsumerStatefulWidget {
|
|
const NewsScreen({super.key});
|
|
|
|
@override
|
|
ConsumerState<NewsScreen> createState() => _NewsScreenState();
|
|
}
|
|
|
|
class _NewsScreenState extends ConsumerState<NewsScreen> {
|
|
final Set<int> _openingChat = {};
|
|
|
|
Future<void> _handleDiscuss(int itemId) async {
|
|
if (_openingChat.contains(itemId)) return;
|
|
setState(() => _openingChat.add(itemId));
|
|
try {
|
|
final conversationId =
|
|
await ref.read(chatApiProvider).openArticleInChat(itemId);
|
|
if (mounted) {
|
|
context.push(Routes.chat.replaceFirst(':id', '$conversationId'));
|
|
}
|
|
} catch (_) {
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(content: Text('Failed to open article in chat.')),
|
|
);
|
|
}
|
|
} finally {
|
|
if (mounted) setState(() => _openingChat.remove(itemId));
|
|
}
|
|
}
|
|
|
|
Future<void> _loadMore() async {
|
|
try {
|
|
await ref.read(newsProvider.notifier).loadMore();
|
|
} catch (_) {
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(content: Text('Failed to load more articles.')),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
Widget _buildNewsItem(NewsState news, int i, {int cols = 1}) {
|
|
if (i >= news.items.length) {
|
|
if (!news.hasMore) return const SizedBox.shrink();
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
|
child: Center(
|
|
child: news.loadingMore
|
|
? const CircularProgressIndicator()
|
|
: FilledButton.tonal(
|
|
onPressed: _loadMore,
|
|
child: const Text('Load more'),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
final item = news.items[i];
|
|
return NewsCard(
|
|
item: RssItemMeta.fromNewsItem(item),
|
|
reaction: news.reactions[item.id],
|
|
snippetMaxLines: cols > 1 ? 5 : 2,
|
|
onReaction: (itemId, reaction) =>
|
|
ref.read(newsProvider.notifier).toggleReaction(itemId, reaction),
|
|
onDiscuss: _openingChat.contains(item.id)
|
|
? null
|
|
: () => _handleDiscuss(item.id),
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final newsAsync = ref.watch(newsProvider);
|
|
final feedsAsync = ref.watch(feedsProvider);
|
|
final scheme = Theme.of(context).colorScheme;
|
|
|
|
return Scaffold(
|
|
appBar: AppBar(
|
|
title: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text('News', style: Theme.of(context).textTheme.titleLarge),
|
|
Text(
|
|
'Last 90 days',
|
|
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
|
color: scheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
body: newsAsync.when(
|
|
loading: () => const Center(child: CircularProgressIndicator()),
|
|
error: (e, _) => Center(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
const Text('Could not load news.'),
|
|
const SizedBox(height: 12),
|
|
FilledButton.tonal(
|
|
onPressed: () => ref.invalidate(newsProvider),
|
|
child: const Text('Retry'),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
data: (news) => Column(
|
|
children: [
|
|
_FeedFilter(
|
|
feeds: feedsAsync.value ?? [],
|
|
selectedFeedId: news.selectedFeedId,
|
|
onChanged: (feedId) =>
|
|
ref.read(newsProvider.notifier).setFeed(feedId),
|
|
),
|
|
const Divider(height: 1),
|
|
Expanded(
|
|
child: RefreshIndicator(
|
|
onRefresh: () => ref.read(newsProvider.notifier).refresh(),
|
|
child: LayoutBuilder(
|
|
builder: (context, constraints) {
|
|
final cols = constraints.maxWidth >= 900
|
|
? 3
|
|
: constraints.maxWidth >= 600
|
|
? 2
|
|
: 1;
|
|
if (cols == 1) {
|
|
return ListView.builder(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 8, vertical: 8),
|
|
itemCount: news.items.length + 1,
|
|
itemBuilder: (_, i) =>
|
|
_buildNewsItem(news, i),
|
|
);
|
|
}
|
|
return CustomScrollView(
|
|
slivers: [
|
|
SliverPadding(
|
|
padding: const EdgeInsets.all(8),
|
|
sliver: SliverGrid(
|
|
gridDelegate:
|
|
SliverGridDelegateWithFixedCrossAxisCount(
|
|
crossAxisCount: cols,
|
|
crossAxisSpacing: 8,
|
|
mainAxisSpacing: 8,
|
|
childAspectRatio: 1.6,
|
|
),
|
|
delegate: SliverChildBuilderDelegate(
|
|
(_, i) => _buildNewsItem(news, i, cols: cols),
|
|
childCount: news.items.length,
|
|
),
|
|
),
|
|
),
|
|
if (news.hasMore)
|
|
SliverToBoxAdapter(
|
|
child: Padding(
|
|
padding:
|
|
const EdgeInsets.symmetric(vertical: 12),
|
|
child: Center(
|
|
child: news.loadingMore
|
|
? const CircularProgressIndicator()
|
|
: FilledButton.tonal(
|
|
onPressed: _loadMore,
|
|
child: const Text('Load more'),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _FeedFilter extends StatelessWidget {
|
|
final List<BriefingFeed> feeds;
|
|
final int? selectedFeedId;
|
|
final void Function(int? feedId) onChanged;
|
|
|
|
const _FeedFilter({
|
|
required this.feeds,
|
|
required this.selectedFeedId,
|
|
required this.onChanged,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Padding(
|
|
padding: const EdgeInsets.fromLTRB(16, 6, 16, 6),
|
|
child: Row(
|
|
children: [
|
|
Text(
|
|
'Feed:',
|
|
style: Theme.of(context).textTheme.labelMedium?.copyWith(
|
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
DropdownButton<int?>(
|
|
value: selectedFeedId,
|
|
underline: const SizedBox.shrink(),
|
|
items: [
|
|
const DropdownMenuItem<int?>(
|
|
value: null,
|
|
child: Text('All feeds'),
|
|
),
|
|
...feeds.map(
|
|
(f) => DropdownMenuItem<int?>(
|
|
value: f.id,
|
|
child: Text(f.title),
|
|
),
|
|
),
|
|
],
|
|
onChanged: (v) => onChanged(v),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|