diff --git a/lib/app.dart b/lib/app.dart index 272acb4..63fa63a 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -159,20 +159,20 @@ final routerProvider = Provider((ref) { path: Routes.conversations, builder: (_, _) => const ConversationsTabScreen(), ), + GoRoute( + path: Routes.projects, + builder: (_, _) => const ProjectsScreen(), + ), + GoRoute( + path: Routes.news, + builder: (_, _) => const NewsScreen(), + ), + GoRoute( + path: Routes.calendar, + builder: (_, _) => const CalendarScreen(), + ), ], ), - GoRoute( - path: Routes.projects, - builder: (_, _) => const ProjectsScreen(), - ), - GoRoute( - path: Routes.news, - builder: (_, _) => const NewsScreen(), - ), - GoRoute( - path: Routes.calendar, - builder: (_, _) => const CalendarScreen(), - ), ], ); }); @@ -190,6 +190,9 @@ class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver { Routes.briefing, Routes.knowledge, Routes.conversations, + Routes.projects, + Routes.news, + Routes.calendar, ]; // Minimum gap between app-resume refreshes to avoid hammering the server. @@ -255,6 +258,10 @@ class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver { ref.read(knowledgeProvider.notifier).refresh(); case 2: ref.read(conversationsProvider.notifier).refresh(); + case 4: + ref.read(newsProvider.notifier).refresh(); + case 5: + ref.read(calendarProvider.notifier).refresh(); } } @@ -271,11 +278,6 @@ class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver { for (var i = 0; i < _tabs.length; i++) { if (location.startsWith(_tabs[i])) return i; } - if (location.startsWith(Routes.projects) || - location.startsWith(Routes.news) || - location.startsWith(Routes.calendar)) { - return 3; - } return 0; } @@ -394,7 +396,7 @@ class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver { final index = _tabIndex(location); // Refresh the incoming tab's data when switching between shell tabs. - if (_prevTabIndex != null && _prevTabIndex != index && index < 3) { + if (_prevTabIndex != null && _prevTabIndex != index) { WidgetsBinding.instance.addPostFrameCallback((_) => _refreshTab(index)); } _prevTabIndex = index; @@ -413,13 +415,7 @@ class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver { children: [ NavigationRail( selectedIndex: index, - onDestinationSelected: (i) { - if (i == 3) { - _showMoreSheet(context); - } else { - context.go(_tabs[i]); - } - }, + onDestinationSelected: (i) => context.go(_tabs[i]), labelType: NavigationRailLabelType.all, destinations: const [ NavigationRailDestination( @@ -438,9 +434,19 @@ class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver { label: Text('Chat'), ), NavigationRailDestination( - icon: Icon(Icons.more_horiz_outlined), - selectedIcon: Icon(Icons.more_horiz), - label: Text('More'), + icon: Icon(Icons.folder_outlined), + selectedIcon: Icon(Icons.folder), + label: Text('Projects'), + ), + NavigationRailDestination( + icon: Icon(Icons.newspaper_outlined), + selectedIcon: Icon(Icons.newspaper), + label: Text('News'), + ), + NavigationRailDestination( + icon: Icon(Icons.calendar_month_outlined), + selectedIcon: Icon(Icons.calendar_month), + label: Text('Calendar'), ), ], ), @@ -469,7 +475,7 @@ class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver { ], ), bottomNavigationBar: NavigationBar( - selectedIndex: index, + selectedIndex: index >= 3 ? 3 : index, onDestinationSelected: (i) { if (i == 3) { _showMoreSheet(context); diff --git a/lib/data/models/knowledge_item.dart b/lib/data/models/knowledge_item.dart index b5e2c2a..05a351c 100644 --- a/lib/data/models/knowledge_item.dart +++ b/lib/data/models/knowledge_item.dart @@ -52,7 +52,7 @@ class KnowledgeItem { id: json['id'] as int, noteType: json['note_type'] as String? ?? 'note', title: json['title'] as String? ?? '', - body: json['body'] as String? ?? '', + body: (json['snippet'] ?? json['body']) as String? ?? '', tags: (json['tags'] as List?) ?.map((e) => e as String) .toList() ?? diff --git a/lib/screens/briefing/briefing_screen.dart b/lib/screens/briefing/briefing_screen.dart index 574cff8..d2d3080 100644 --- a/lib/screens/briefing/briefing_screen.dart +++ b/lib/screens/briefing/briefing_screen.dart @@ -266,7 +266,8 @@ class _BriefingScreenState extends ConsumerState ), ), data: (conv) { - return Column( + final isWide = MediaQuery.of(context).size.width >= 600; + Widget body = Column( children: [ Expanded( child: RefreshIndicator( @@ -392,6 +393,15 @@ class _BriefingScreenState extends ConsumerState ), ], ); + if (isWide) { + body = Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 700), + child: body, + ), + ); + } + return body; }, ), ); diff --git a/lib/screens/chat/conversations_tab_screen.dart b/lib/screens/chat/conversations_tab_screen.dart index ee4f541..da7e1b9 100644 --- a/lib/screens/chat/conversations_tab_screen.dart +++ b/lib/screens/chat/conversations_tab_screen.dart @@ -4,30 +4,79 @@ import 'package:go_router/go_router.dart'; import '../../core/constants.dart'; import '../../providers/chat_provider.dart'; +import 'chat_screen.dart'; -class ConversationsTabScreen extends ConsumerWidget { +class ConversationsTabScreen extends ConsumerStatefulWidget { const ConversationsTabScreen({super.key}); @override - Widget build(BuildContext context, WidgetRef ref) { + ConsumerState createState() => + _ConversationsTabScreenState(); +} + +class _ConversationsTabScreenState + extends ConsumerState { + int? _selectedConvId; + + Future _createConversation() async { + final conv = + await ref.read(conversationsProvider.notifier).create(''); + if (!mounted) return; + final isWide = MediaQuery.of(context).size.width >= 600; + if (isWide) { + setState(() => _selectedConvId = conv.id); + } else { + context.push(Routes.chat.replaceFirst(':id', '${conv.id}')); + } + } + + void _openConversation(int id) { + final isWide = MediaQuery.of(context).size.width >= 600; + if (isWide) { + setState(() => _selectedConvId = id); + } else { + context.push(Routes.chat.replaceFirst(':id', '$id')); + } + } + + Future _confirmDelete(int id, String title) async { + final ok = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: const Text('Delete conversation?'), + content: Text('"$title" will be permanently deleted.'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext, false), + child: const Text('Cancel')), + FilledButton( + onPressed: () => Navigator.pop(dialogContext, true), + child: const Text('Delete')), + ], + ), + ); + if (ok == true) { + await ref.read(conversationsProvider.notifier).delete(id); + if (_selectedConvId == id) { + setState(() => _selectedConvId = null); + } + } + } + + @override + Widget build(BuildContext context) { + final isWide = MediaQuery.of(context).size.width >= 600; final theme = Theme.of(context); final convsAsync = ref.watch(conversationsProvider); - return Scaffold( + final listPanel = Scaffold( appBar: AppBar( title: Text('Chat', style: theme.textTheme.titleLarge), actions: [ IconButton( icon: const Icon(Icons.add), tooltip: 'New conversation', - onPressed: () async { - final conv = await ref - .read(conversationsProvider.notifier) - .create(''); - if (context.mounted) { - context.push(Routes.chat.replaceFirst(':id', '${conv.id}')); - } - }, + onPressed: _createConversation, ), ], ), @@ -49,26 +98,20 @@ class ConversationsTabScreen extends ConsumerWidget { FilledButton.icon( icon: const Icon(Icons.add), label: const Text('Start a conversation'), - onPressed: () async { - final conv = await ref - .read(conversationsProvider.notifier) - .create(''); - if (context.mounted) { - context.push( - Routes.chat.replaceFirst(':id', '${conv.id}')); - } - }, + onPressed: _createConversation, ), ], ), ); } return RefreshIndicator( - onRefresh: () => ref.read(conversationsProvider.notifier).refresh(), + onRefresh: () => + ref.read(conversationsProvider.notifier).refresh(), child: ListView.builder( itemCount: convs.length, itemBuilder: (ctx, i) { final c = convs[i]; + final selected = isWide && c.id == _selectedConvId; return ListTile( leading: const Icon(Icons.chat_bubble_outline), title: Text( @@ -79,13 +122,12 @@ class ConversationsTabScreen extends ConsumerWidget { _relativeTime(c.updatedAt), style: theme.textTheme.labelSmall, ), + selected: selected, trailing: IconButton( icon: const Icon(Icons.delete_outline), - onPressed: () => - _confirmDelete(context, ref, c.id, c.title), + onPressed: () => _confirmDelete(c.id, c.title), ), - onTap: () => - ctx.push(Routes.chat.replaceFirst(':id', '${c.id}')), + onTap: () => _openConversation(c.id), ); }, ), @@ -93,28 +135,38 @@ class ConversationsTabScreen extends ConsumerWidget { }, ), ); - } - Future _confirmDelete( - BuildContext context, WidgetRef ref, int id, String title) async { - final ok = await showDialog( - context: context, - builder: (dialogContext) => AlertDialog( - title: const Text('Delete conversation?'), - content: Text('"$title" will be permanently deleted.'), - actions: [ - TextButton( - onPressed: () => Navigator.pop(dialogContext, false), - child: const Text('Cancel')), - FilledButton( - onPressed: () => Navigator.pop(dialogContext, true), - child: const Text('Delete')), - ], - ), + if (!isWide) return listPanel; + + return Row( + children: [ + SizedBox( + width: 320, + child: listPanel, + ), + const VerticalDivider(width: 1), + Expanded( + child: _selectedConvId != null + ? ChatScreen( + key: ValueKey(_selectedConvId), + conversationId: _selectedConvId!, + ) + : Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.chat_bubble_outline, + size: 48, + color: theme.colorScheme.onSurfaceVariant), + const SizedBox(height: 16), + Text('Select a conversation', + style: theme.textTheme.titleMedium), + ], + ), + ), + ), + ], ); - if (ok == true) { - await ref.read(conversationsProvider.notifier).delete(id); - } } } diff --git a/lib/screens/knowledge/knowledge_screen.dart b/lib/screens/knowledge/knowledge_screen.dart index 0fbe1d8..4a9d72b 100644 --- a/lib/screens/knowledge/knowledge_screen.dart +++ b/lib/screens/knowledge/knowledge_screen.dart @@ -186,18 +186,57 @@ class _KnowledgeScreenState extends ConsumerState } return RefreshIndicator( onRefresh: () => ref.read(knowledgeProvider.notifier).refresh(), - child: ListView.separated( - controller: _scrollController, - itemCount: items.length + (state.isLoadingBatch ? 1 : 0), - separatorBuilder: (_, _) => const Divider(height: 1), - itemBuilder: (_, i) { - if (i >= items.length) { - return const Padding( - padding: EdgeInsets.all(16), - child: Center(child: CircularProgressIndicator()), + child: LayoutBuilder( + builder: (context, constraints) { + final cols = constraints.maxWidth >= 900 + ? 3 + : constraints.maxWidth >= 600 + ? 2 + : 1; + if (cols == 1) { + return ListView.separated( + controller: _scrollController, + itemCount: items.length + (state.isLoadingBatch ? 1 : 0), + separatorBuilder: (_, _) => const Divider(height: 1), + itemBuilder: (_, i) { + if (i >= items.length) { + return const Padding( + padding: EdgeInsets.all(16), + child: Center(child: CircularProgressIndicator()), + ); + } + return KnowledgeItemCard(item: items[i]); + }, ); } - return KnowledgeItemCard(item: items[i]); + return CustomScrollView( + controller: _scrollController, + slivers: [ + SliverPadding( + padding: const EdgeInsets.all(8), + sliver: SliverGrid( + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: cols, + crossAxisSpacing: 8, + mainAxisSpacing: 8, + childAspectRatio: 1.8, + ), + delegate: SliverChildBuilderDelegate( + (_, i) { + if (i >= items.length) { + return const Center( + child: CircularProgressIndicator()); + } + return KnowledgeItemCard(item: items[i]) + .buildGridCard(context); + }, + childCount: + items.length + (state.isLoadingBatch ? 1 : 0), + ), + ), + ), + ], + ); }, ), ); diff --git a/lib/screens/news/news_screen.dart b/lib/screens/news/news_screen.dart index 760d930..18dcd1c 100644 --- a/lib/screens/news/news_screen.dart +++ b/lib/screens/news/news_screen.dart @@ -50,6 +50,34 @@ class _NewsScreenState extends ConsumerState { } } + 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); @@ -98,38 +126,59 @@ class _NewsScreenState extends ConsumerState { Expanded( child: RefreshIndicator( onRefresh: () => ref.read(newsProvider.notifier).refresh(), - child: ListView.builder( - padding: - const EdgeInsets.symmetric(horizontal: 8, vertical: 8), - itemCount: news.items.length + 1, - itemBuilder: (_, i) { - 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'), + 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'), + ), ), - ), + ), + ), + ], ); - } - final item = news.items[i]; - return NewsCard( - item: RssItemMeta.fromNewsItem(item), - reaction: news.reactions[item.id], - onReaction: (itemId, reaction) => ref - .read(newsProvider.notifier) - .toggleReaction(itemId, reaction), - onDiscuss: _openingChat.contains(item.id) - ? null - : () => _handleDiscuss(item.id), - ); - }, - ), + }, + ), ), ), ], diff --git a/lib/widgets/knowledge_item_card.dart b/lib/widgets/knowledge_item_card.dart index ca5b09c..9af1688 100644 --- a/lib/widgets/knowledge_item_card.dart +++ b/lib/widgets/knowledge_item_card.dart @@ -27,12 +27,24 @@ class KnowledgeItemCard extends StatelessWidget { String? get _subtitle { if (item.noteType == 'task') { + if (item.body.trim().isNotEmpty) { + final preview = item.body.trim().replaceAll('\n', ' '); + return preview.length > 200 ? '${preview.substring(0, 200)}…' : preview; + } if (item.dueDate != null) return 'Due ${item.dueDate}'; return item.status; } if (item.body.trim().isEmpty) return null; final preview = item.body.trim().replaceAll('\n', ' '); - return preview.length > 120 ? '${preview.substring(0, 120)}…' : preview; + return preview.length > 200 ? '${preview.substring(0, 200)}…' : preview; + } + + void _onTap(BuildContext context) { + if (item.noteType == 'task') { + context.push('/tasks/${item.id}/edit'); + } else { + context.push('/notes/${item.id}'); + } } @override @@ -53,13 +65,64 @@ class KnowledgeItemCard extends StatelessWidget { ) : null, trailing: item.tags.isNotEmpty ? _TagChips(tags: item.tags) : null, - onTap: () { - if (item.noteType == 'task') { - context.push('/tasks/${item.id}/edit'); - } else { - context.push('/notes/${item.id}'); - } - }, + onTap: () => _onTap(context), + ); + } + + Widget buildGridCard(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; + return Card( + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + side: BorderSide(color: scheme.outlineVariant), + ), + child: InkWell( + borderRadius: BorderRadius.circular(10), + onTap: () => _onTap(context), + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(_icon, size: 18, color: _statusColor(context)), + const SizedBox(width: 8), + Expanded( + child: Text( + item.title.isEmpty ? '(untitled)' : item.title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: textTheme.titleSmall + ?.copyWith(fontWeight: FontWeight.w600), + ), + ), + ], + ), + if (_subtitle != null) ...[ + const SizedBox(height: 8), + Expanded( + child: Text( + _subtitle!, + overflow: TextOverflow.ellipsis, + maxLines: 4, + style: textTheme.bodySmall?.copyWith( + color: scheme.onSurfaceVariant, + height: 1.4, + ), + ), + ), + ], + if (item.tags.isNotEmpty) ...[ + const Spacer(), + _TagChips(tags: item.tags), + ], + ], + ), + ), + ), ); } } diff --git a/lib/widgets/news_card.dart b/lib/widgets/news_card.dart index 21f9537..9363c4b 100644 --- a/lib/widgets/news_card.dart +++ b/lib/widgets/news_card.dart @@ -54,6 +54,7 @@ class NewsCard extends StatelessWidget { final String? reaction; // 'up' | 'down' | null final void Function(int itemId, String reaction) onReaction; final VoidCallback? onDiscuss; + final int snippetMaxLines; const NewsCard({ super.key, @@ -61,6 +62,7 @@ class NewsCard extends StatelessWidget { required this.reaction, required this.onReaction, this.onDiscuss, + this.snippetMaxLines = 2, }); Future _openUrl() async { @@ -135,7 +137,7 @@ class NewsCard extends StatelessWidget { const SizedBox(height: 4), Text( item.snippet, - maxLines: 2, + maxLines: snippetMaxLines, overflow: TextOverflow.ellipsis, style: textTheme.bodySmall?.copyWith( color: scheme.onSurfaceVariant,