feat: app overhaul — Briefing-first navigation, Library, capture queue

Plans 1-3 implemented:

Plan 1 — Foundation
- Add google_fonts ^6.2.1
- lib/core/theme.dart: slate-indigo ColorScheme (dark/light), Fraunces
  headings, GradientButton widget
- lib/providers/capture_work_queue_provider.dart: in-memory sequential
  work queue; CaptureWorkQueueNotifier drains one item at a time;
  captureResultProvider feeds snackbars to UI
- lib/app.dart: wire fabledDarkTheme/fabledLightTheme; replace blocking
  _QuickCaptureBar with queue-based implementation (progress bar, badge)

Plan 2 — Navigation
- 3-tab shell: Briefing · Library · Chat (was Notes · Tasks · Projects · Chat)
- lib/screens/library/library_screen.dart: unified notes+tasks+projects list
  with filter pills, status sub-filter for tasks, live search, FAB
- lib/widgets/library_item_card.dart: NoteLibraryCard, TaskLibraryCard
  (status cycle), ProjectLibraryCard
- lib/screens/chat/conversations_tab_screen.dart: focused replacement for
  ConversationsListScreen
- Delete 5 dead screens: notes_list, tasks_list, project_list,
  conversations_list, quick_capture

Plan 3 — Briefing
- lib/data/models/briefing_conversation.dart
- lib/data/api/briefing_api.dart: getToday, getHistory, getMessages,
  triggerSlot
- lib/providers/briefing_provider.dart: BriefingNotifier with sendReply
  (optimistic + SSE + poll, same pattern as MessagesNotifier) and refresh
- lib/widgets/chat_message_bubble.dart: extracted + redesigned shared bubble
  (ghost user bubbles, left-accent assistant bubbles)
- lib/widgets/briefing_digest_card.dart: expandable first-message card
- lib/screens/briefing/briefing_screen.dart: digest card, conversation list,
  streaming reply bar, refresh button, history overflow menu
- lib/screens/briefing/briefing_history_screen.dart: read-only past dates

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-11 23:17:38 -04:00
parent 3422caebfc
commit 6232c7c99a
25 changed files with 3830 additions and 1593 deletions
+2 -44
View File
@@ -1,12 +1,9 @@
import 'dart:math' show min;
import 'package:flutter/material.dart';
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/exceptions.dart';
import '../../data/models/message.dart';
import '../../providers/chat_provider.dart';
import '../../widgets/chat_message_bubble.dart';
class ChatScreen extends ConsumerStatefulWidget {
final int conversationId;
@@ -102,7 +99,7 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
horizontal: 8, vertical: 12),
itemCount: messages.length,
itemBuilder: (context, i) =>
_MessageBubble(message: messages[i]),
ChatMessageBubble(message: messages[i]),
);
},
),
@@ -153,42 +150,3 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
}
}
class _MessageBubble extends StatelessWidget {
final Message message;
const _MessageBubble({required this.message});
@override
Widget build(BuildContext context) {
final isUser = message.role == MessageRole.user;
final scheme = Theme.of(context).colorScheme;
return Align(
alignment: isUser ? Alignment.centerRight : Alignment.centerLeft,
child: Container(
constraints: BoxConstraints(
maxWidth: min(MediaQuery.of(context).size.width * 0.8, 480),
),
margin: const EdgeInsets.symmetric(vertical: 4),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: isUser ? scheme.primary : scheme.surfaceContainerHighest,
borderRadius: BorderRadius.only(
topLeft: const Radius.circular(16),
topRight: const Radius.circular(16),
bottomLeft: Radius.circular(isUser ? 16 : 4),
bottomRight: Radius.circular(isUser ? 4 : 16),
),
),
child: isUser
? Text(
message.content,
style: TextStyle(color: scheme.onPrimary),
)
: MarkdownBody(
data: message.content.isEmpty ? '...' : message.content,
styleSheet: MarkdownStyleSheet.fromTheme(Theme.of(context)),
),
),
);
}
}
@@ -1,185 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/constants.dart';
import '../../core/exceptions.dart';
import '../../providers/chat_provider.dart';
import 'chat_screen.dart';
class ConversationsListScreen extends ConsumerStatefulWidget {
const ConversationsListScreen({super.key});
@override
ConsumerState<ConversationsListScreen> createState() =>
_ConversationsListScreenState();
}
class _ConversationsListScreenState
extends ConsumerState<ConversationsListScreen> {
int? _selectedConvId;
Future<void> _newConversation(bool isWide) async {
try {
final conv = await ref.read(conversationsProvider.notifier).create('');
if (!mounted) return;
if (isWide) {
setState(() => _selectedConvId = conv.id);
} else {
context.push(Routes.chat.replaceFirst(':id', '${conv.id}'));
}
} on AppException catch (e) {
if (mounted) {
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(e.message)));
}
}
}
@override
Widget build(BuildContext context) {
final convsAsync = ref.watch(conversationsProvider);
final isWide = MediaQuery.of(context).size.width >= 600;
// Clear stale selection when switching to narrow mode.
if (!isWide && _selectedConvId != null) {
_selectedConvId = null;
}
return Scaffold(
body: isWide
? Row(
children: [
SizedBox(
width: 300,
child: Column(
children: [
Expanded(child: _buildListPane(convsAsync, isWide)),
const Divider(height: 1),
ListTile(
leading: const Icon(Icons.add),
title: const Text('New conversation'),
onTap: () => _newConversation(isWide),
),
],
),
),
const VerticalDivider(width: 1),
Expanded(child: _buildDetailPane()),
],
)
: _buildListPane(convsAsync, isWide),
floatingActionButton: isWide
? null
: FloatingActionButton(
heroTag: 'chat_fab',
onPressed: () => _newConversation(isWide),
child: const Icon(Icons.add),
),
);
}
Widget _buildDetailPane() {
if (_selectedConvId == null) {
return const Center(child: Text('Select a conversation to open it.'));
}
return ChatScreen(
key: ValueKey(_selectedConvId),
conversationId: _selectedConvId!,
);
}
Widget _buildListPane(AsyncValue convsAsync, bool isWide) {
return convsAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (_, _) => Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.cloud_off, size: 48),
const SizedBox(height: 12),
const Text('Could not load conversations.'),
const SizedBox(height: 4),
TextButton(
onPressed: () => ref.invalidate(conversationsProvider),
child: const Text('Retry'),
),
],
),
),
data: (convs) {
if (convs.isEmpty) {
return const Center(
child: Text('No conversations yet. Tap + to start one.'));
}
return RefreshIndicator(
onRefresh: () => ref.refresh(conversationsProvider.future),
child: ListView.separated(
itemCount: convs.length,
separatorBuilder: (_, _) => const Divider(height: 1),
itemBuilder: (context, i) {
final conv = convs[i];
return ListTile(
leading: const Icon(Icons.chat_bubble_outline),
title: Text(
conv.title.isNotEmpty ? conv.title : 'New conversation',
),
subtitle: Text(
conv.updatedAt.toLocal().toString().substring(0, 16),
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
selected: isWide && _selectedConvId == conv.id,
selectedTileColor:
Theme.of(context).colorScheme.secondaryContainer,
onTap: () {
if (isWide) {
setState(() => _selectedConvId = conv.id);
} else {
context.push(
Routes.chat.replaceFirst(':id', '${conv.id}'),
);
}
},
onLongPress: () async {
final confirm = await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('Delete conversation?'),
content: Text(
conv.title.isNotEmpty
? conv.title
: 'New conversation',
),
actions: [
TextButton(
onPressed: () =>
Navigator.pop(dialogContext, false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () =>
Navigator.pop(dialogContext, true),
child: const Text('Delete'),
),
],
),
);
if (confirm == true) {
await ref
.read(conversationsProvider.notifier)
.delete(conv.id);
if (mounted && _selectedConvId == conv.id) {
setState(() => _selectedConvId = null);
}
}
},
);
},
),
);
},
);
}
}
@@ -0,0 +1,126 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/constants.dart';
import '../../providers/chat_provider.dart';
class ConversationsTabScreen extends ConsumerWidget {
const ConversationsTabScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
final convsAsync = ref.watch(conversationsProvider);
return 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('New conversation');
if (context.mounted) {
context.push(Routes.chat.replaceFirst(':id', '${conv.id}'));
}
},
),
],
),
body: convsAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(child: Text('Error: $e')),
data: (convs) {
if (convs.isEmpty) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.chat_bubble_outline,
size: 48, color: theme.colorScheme.onSurfaceVariant),
const SizedBox(height: 16),
Text('No conversations yet',
style: theme.textTheme.titleMedium),
const SizedBox(height: 8),
FilledButton.icon(
icon: const Icon(Icons.add),
label: const Text('Start a conversation'),
onPressed: () async {
final conv = await ref
.read(conversationsProvider.notifier)
.create('New conversation');
if (context.mounted) {
context.push(
Routes.chat.replaceFirst(':id', '${conv.id}'));
}
},
),
],
),
);
}
return RefreshIndicator(
onRefresh: () async => ref.invalidate(conversationsProvider),
child: ListView.builder(
itemCount: convs.length,
itemBuilder: (ctx, i) {
final c = convs[i];
return ListTile(
leading: const Icon(Icons.chat_bubble_outline),
title: Text(c.title,
maxLines: 1, overflow: TextOverflow.ellipsis),
subtitle: Text(
_relativeTime(c.updatedAt),
style: theme.textTheme.labelSmall,
),
trailing: IconButton(
icon: const Icon(Icons.delete_outline),
onPressed: () =>
_confirmDelete(context, ref, c.id, c.title),
),
onTap: () =>
ctx.push(Routes.chat.replaceFirst(':id', '${c.id}')),
);
},
),
);
},
),
);
}
Future<void> _confirmDelete(
BuildContext context, WidgetRef ref, int id, String title) async {
final ok = await showDialog<bool>(
context: context,
builder: (_) => AlertDialog(
title: const Text('Delete conversation?'),
content: Text('"$title" will be permanently deleted.'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel')),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Delete')),
],
),
);
if (ok == true) {
await ref.read(conversationsProvider.notifier).delete(id);
}
}
}
String _relativeTime(DateTime dt) {
final diff = DateTime.now().difference(dt);
if (diff.inMinutes < 1) return 'just now';
if (diff.inHours < 1) return '${diff.inMinutes}m ago';
if (diff.inDays < 1) return '${diff.inHours}h ago';
if (diff.inDays < 7) return '${diff.inDays}d ago';
return '${dt.day}/${dt.month}/${dt.year}';
}