feat: tablet landscape improvements

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>
This commit is contained in:
2026-04-16 01:31:52 -04:00
parent bdaa5210f0
commit 58d4cfab4d
8 changed files with 346 additions and 125 deletions
+35 -29
View File
@@ -159,20 +159,20 @@ final routerProvider = Provider<GoRouter>((ref) {
path: Routes.conversations, path: Routes.conversations,
builder: (_, _) => const ConversationsTabScreen(), 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.briefing,
Routes.knowledge, Routes.knowledge,
Routes.conversations, Routes.conversations,
Routes.projects,
Routes.news,
Routes.calendar,
]; ];
// Minimum gap between app-resume refreshes to avoid hammering the server. // 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(); ref.read(knowledgeProvider.notifier).refresh();
case 2: case 2:
ref.read(conversationsProvider.notifier).refresh(); 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++) { for (var i = 0; i < _tabs.length; i++) {
if (location.startsWith(_tabs[i])) return 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; return 0;
} }
@@ -394,7 +396,7 @@ class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver {
final index = _tabIndex(location); final index = _tabIndex(location);
// Refresh the incoming tab's data when switching between shell tabs. // 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)); WidgetsBinding.instance.addPostFrameCallback((_) => _refreshTab(index));
} }
_prevTabIndex = index; _prevTabIndex = index;
@@ -413,13 +415,7 @@ class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver {
children: [ children: [
NavigationRail( NavigationRail(
selectedIndex: index, selectedIndex: index,
onDestinationSelected: (i) { onDestinationSelected: (i) => context.go(_tabs[i]),
if (i == 3) {
_showMoreSheet(context);
} else {
context.go(_tabs[i]);
}
},
labelType: NavigationRailLabelType.all, labelType: NavigationRailLabelType.all,
destinations: const [ destinations: const [
NavigationRailDestination( NavigationRailDestination(
@@ -438,9 +434,19 @@ class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver {
label: Text('Chat'), label: Text('Chat'),
), ),
NavigationRailDestination( NavigationRailDestination(
icon: Icon(Icons.more_horiz_outlined), icon: Icon(Icons.folder_outlined),
selectedIcon: Icon(Icons.more_horiz), selectedIcon: Icon(Icons.folder),
label: Text('More'), 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( bottomNavigationBar: NavigationBar(
selectedIndex: index, selectedIndex: index >= 3 ? 3 : index,
onDestinationSelected: (i) { onDestinationSelected: (i) {
if (i == 3) { if (i == 3) {
_showMoreSheet(context); _showMoreSheet(context);
+1 -1
View File
@@ -52,7 +52,7 @@ class KnowledgeItem {
id: json['id'] as int, id: json['id'] as int,
noteType: json['note_type'] as String? ?? 'note', noteType: json['note_type'] as String? ?? 'note',
title: json['title'] as String? ?? '', title: json['title'] as String? ?? '',
body: json['body'] as String? ?? '', body: (json['snippet'] ?? json['body']) as String? ?? '',
tags: (json['tags'] as List<dynamic>?) tags: (json['tags'] as List<dynamic>?)
?.map((e) => e as String) ?.map((e) => e as String)
.toList() ?? .toList() ??
+11 -1
View File
@@ -266,7 +266,8 @@ class _BriefingScreenState extends ConsumerState<BriefingScreen>
), ),
), ),
data: (conv) { data: (conv) {
return Column( final isWide = MediaQuery.of(context).size.width >= 600;
Widget body = Column(
children: [ children: [
Expanded( Expanded(
child: RefreshIndicator( child: RefreshIndicator(
@@ -392,6 +393,15 @@ class _BriefingScreenState extends ConsumerState<BriefingScreen>
), ),
], ],
); );
if (isWide) {
body = Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 700),
child: body,
),
);
}
return body;
}, },
), ),
); );
+97 -45
View File
@@ -4,30 +4,79 @@ import 'package:go_router/go_router.dart';
import '../../core/constants.dart'; import '../../core/constants.dart';
import '../../providers/chat_provider.dart'; import '../../providers/chat_provider.dart';
import 'chat_screen.dart';
class ConversationsTabScreen extends ConsumerWidget { class ConversationsTabScreen extends ConsumerStatefulWidget {
const ConversationsTabScreen({super.key}); const ConversationsTabScreen({super.key});
@override @override
Widget build(BuildContext context, WidgetRef ref) { ConsumerState<ConversationsTabScreen> createState() =>
_ConversationsTabScreenState();
}
class _ConversationsTabScreenState
extends ConsumerState<ConversationsTabScreen> {
int? _selectedConvId;
Future<void> _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<void> _confirmDelete(int id, String title) async {
final ok = await showDialog<bool>(
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 theme = Theme.of(context);
final convsAsync = ref.watch(conversationsProvider); final convsAsync = ref.watch(conversationsProvider);
return Scaffold( final listPanel = Scaffold(
appBar: AppBar( appBar: AppBar(
title: Text('Chat', style: theme.textTheme.titleLarge), title: Text('Chat', style: theme.textTheme.titleLarge),
actions: [ actions: [
IconButton( IconButton(
icon: const Icon(Icons.add), icon: const Icon(Icons.add),
tooltip: 'New conversation', tooltip: 'New conversation',
onPressed: () async { onPressed: _createConversation,
final conv = await ref
.read(conversationsProvider.notifier)
.create('');
if (context.mounted) {
context.push(Routes.chat.replaceFirst(':id', '${conv.id}'));
}
},
), ),
], ],
), ),
@@ -49,26 +98,20 @@ class ConversationsTabScreen extends ConsumerWidget {
FilledButton.icon( FilledButton.icon(
icon: const Icon(Icons.add), icon: const Icon(Icons.add),
label: const Text('Start a conversation'), label: const Text('Start a conversation'),
onPressed: () async { onPressed: _createConversation,
final conv = await ref
.read(conversationsProvider.notifier)
.create('');
if (context.mounted) {
context.push(
Routes.chat.replaceFirst(':id', '${conv.id}'));
}
},
), ),
], ],
), ),
); );
} }
return RefreshIndicator( return RefreshIndicator(
onRefresh: () => ref.read(conversationsProvider.notifier).refresh(), onRefresh: () =>
ref.read(conversationsProvider.notifier).refresh(),
child: ListView.builder( child: ListView.builder(
itemCount: convs.length, itemCount: convs.length,
itemBuilder: (ctx, i) { itemBuilder: (ctx, i) {
final c = convs[i]; final c = convs[i];
final selected = isWide && c.id == _selectedConvId;
return ListTile( return ListTile(
leading: const Icon(Icons.chat_bubble_outline), leading: const Icon(Icons.chat_bubble_outline),
title: Text( title: Text(
@@ -79,13 +122,12 @@ class ConversationsTabScreen extends ConsumerWidget {
_relativeTime(c.updatedAt), _relativeTime(c.updatedAt),
style: theme.textTheme.labelSmall, style: theme.textTheme.labelSmall,
), ),
selected: selected,
trailing: IconButton( trailing: IconButton(
icon: const Icon(Icons.delete_outline), icon: const Icon(Icons.delete_outline),
onPressed: () => onPressed: () => _confirmDelete(c.id, c.title),
_confirmDelete(context, ref, c.id, c.title),
), ),
onTap: () => onTap: () => _openConversation(c.id),
ctx.push(Routes.chat.replaceFirst(':id', '${c.id}')),
); );
}, },
), ),
@@ -93,28 +135,38 @@ class ConversationsTabScreen extends ConsumerWidget {
}, },
), ),
); );
}
Future<void> _confirmDelete( if (!isWide) return listPanel;
BuildContext context, WidgetRef ref, int id, String title) async {
final ok = await showDialog<bool>( return Row(
context: context, children: [
builder: (dialogContext) => AlertDialog( SizedBox(
title: const Text('Delete conversation?'), width: 320,
content: Text('"$title" will be permanently deleted.'), child: listPanel,
actions: [ ),
TextButton( const VerticalDivider(width: 1),
onPressed: () => Navigator.pop(dialogContext, false), Expanded(
child: const Text('Cancel')), child: _selectedConvId != null
FilledButton( ? ChatScreen(
onPressed: () => Navigator.pop(dialogContext, true), key: ValueKey(_selectedConvId),
child: const Text('Delete')), 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);
}
} }
} }
+49 -10
View File
@@ -186,18 +186,57 @@ class _KnowledgeScreenState extends ConsumerState<KnowledgeScreen>
} }
return RefreshIndicator( return RefreshIndicator(
onRefresh: () => ref.read(knowledgeProvider.notifier).refresh(), onRefresh: () => ref.read(knowledgeProvider.notifier).refresh(),
child: ListView.separated( child: LayoutBuilder(
controller: _scrollController, builder: (context, constraints) {
itemCount: items.length + (state.isLoadingBatch ? 1 : 0), final cols = constraints.maxWidth >= 900
separatorBuilder: (_, _) => const Divider(height: 1), ? 3
itemBuilder: (_, i) { : constraints.maxWidth >= 600
if (i >= items.length) { ? 2
return const Padding( : 1;
padding: EdgeInsets.all(16), if (cols == 1) {
child: Center(child: CircularProgressIndicator()), 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),
),
),
),
],
);
}, },
), ),
); );
+79 -30
View File
@@ -50,6 +50,34 @@ class _NewsScreenState extends ConsumerState<NewsScreen> {
} }
} }
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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final newsAsync = ref.watch(newsProvider); final newsAsync = ref.watch(newsProvider);
@@ -98,38 +126,59 @@ class _NewsScreenState extends ConsumerState<NewsScreen> {
Expanded( Expanded(
child: RefreshIndicator( child: RefreshIndicator(
onRefresh: () => ref.read(newsProvider.notifier).refresh(), onRefresh: () => ref.read(newsProvider.notifier).refresh(),
child: ListView.builder( child: LayoutBuilder(
padding: builder: (context, constraints) {
const EdgeInsets.symmetric(horizontal: 8, vertical: 8), final cols = constraints.maxWidth >= 900
itemCount: news.items.length + 1, ? 3
itemBuilder: (_, i) { : constraints.maxWidth >= 600
if (i == news.items.length) { ? 2
if (!news.hasMore) return const SizedBox.shrink(); : 1;
return Padding( if (cols == 1) {
padding: const EdgeInsets.symmetric(vertical: 8), return ListView.builder(
child: Center( padding: const EdgeInsets.symmetric(
child: news.loadingMore horizontal: 8, vertical: 8),
? const CircularProgressIndicator() itemCount: news.items.length + 1,
: FilledButton.tonal( itemBuilder: (_, i) =>
onPressed: _loadMore, _buildNewsItem(news, i),
child: const Text('Load more'), );
}
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),
);
},
),
), ),
), ),
], ],
+71 -8
View File
@@ -27,12 +27,24 @@ class KnowledgeItemCard extends StatelessWidget {
String? get _subtitle { String? get _subtitle {
if (item.noteType == 'task') { 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}'; if (item.dueDate != null) return 'Due ${item.dueDate}';
return item.status; return item.status;
} }
if (item.body.trim().isEmpty) return null; if (item.body.trim().isEmpty) return null;
final preview = item.body.trim().replaceAll('\n', ' '); 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 @override
@@ -53,13 +65,64 @@ class KnowledgeItemCard extends StatelessWidget {
) )
: null, : null,
trailing: item.tags.isNotEmpty ? _TagChips(tags: item.tags) : null, trailing: item.tags.isNotEmpty ? _TagChips(tags: item.tags) : null,
onTap: () { onTap: () => _onTap(context),
if (item.noteType == 'task') { );
context.push('/tasks/${item.id}/edit'); }
} else {
context.push('/notes/${item.id}'); 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),
],
],
),
),
),
); );
} }
} }
+3 -1
View File
@@ -54,6 +54,7 @@ class NewsCard extends StatelessWidget {
final String? reaction; // 'up' | 'down' | null final String? reaction; // 'up' | 'down' | null
final void Function(int itemId, String reaction) onReaction; final void Function(int itemId, String reaction) onReaction;
final VoidCallback? onDiscuss; final VoidCallback? onDiscuss;
final int snippetMaxLines;
const NewsCard({ const NewsCard({
super.key, super.key,
@@ -61,6 +62,7 @@ class NewsCard extends StatelessWidget {
required this.reaction, required this.reaction,
required this.onReaction, required this.onReaction,
this.onDiscuss, this.onDiscuss,
this.snippetMaxLines = 2,
}); });
Future<void> _openUrl() async { Future<void> _openUrl() async {
@@ -135,7 +137,7 @@ class NewsCard extends StatelessWidget {
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
item.snippet, item.snippet,
maxLines: 2, maxLines: snippetMaxLines,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: textTheme.bodySmall?.copyWith( style: textTheme.bodySmall?.copyWith(
color: scheme.onSurfaceVariant, color: scheme.onSurfaceVariant,