37 KiB
Fabled App Overhaul — Plan 3: Briefing Screen
For agentic workers: REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Build the full BriefingScreen — today's briefing conversation with digest card, streaming reply bar, history navigation — backed by a new BriefingApi and BriefingNotifier.
Architecture: A new BriefingApi calls the /api/briefing/ endpoints for today's conversation and history; replies go through the existing /api/chat/ message + SSE endpoints (briefing conversations are regular Conversations with conversation_type = "briefing"). BriefingNotifier (AsyncNotifier) owns the today conversation state with sendReply and refresh methods that mirror the existing MessagesNotifier pattern. A ChatMessageBubble widget is extracted from ChatScreen (applying the design-language styles from the spec) and shared. The BriefingScreen uses ChatMessageBubble for the conversation list, with a BriefingDigestCard pinned above it for the first assistant message.
Tech Stack: Flutter/Dart, Riverpod, Dio (existing), custom ColorScheme from Plan 1.
Dependency: Plans 1 and 2 must be applied first (theme + shell). This plan's tasks can otherwise be executed in order.
File Map
Create:
lib/data/models/briefing_conversation.dart—BriefingConversationmodellib/data/api/briefing_api.dart—BriefingApi(getToday, getHistory, getMessages, triggerSlot)lib/providers/briefing_provider.dart—BriefingNotifier,isBriefingStreamingProviderlib/widgets/chat_message_bubble.dart— extracted + styled bubble widget (shared by Chat + Briefing)lib/widgets/briefing_digest_card.dart— expandable first-message cardlib/screens/briefing/briefing_history_screen.dart— read-only past briefings listlib/screens/briefing/briefing_screen.dart— full implementation (replaces Plan 2 placeholder)
Modify:
lib/providers/api_client_provider.dart— addbriefingApiProviderlib/screens/chat/chat_screen.dart— replace_MessageBubblewithChatMessageBubble
Chunk 1: Data Layer
Task 1: BriefingConversation model + BriefingApi
Files:
-
Create:
lib/data/models/briefing_conversation.dart -
Create:
lib/data/api/briefing_api.dart -
Step 1: Create the model
Create lib/data/models/briefing_conversation.dart:
import 'message.dart';
class BriefingConversation {
final int id;
final String title;
final String? briefingDate; // YYYY-MM-DD or null
final List<Message> messages;
const BriefingConversation({
required this.id,
required this.title,
this.briefingDate,
required this.messages,
});
factory BriefingConversation.fromJson(Map<String, dynamic> json) {
final rawMessages = json['messages'] as List<dynamic>? ?? [];
return BriefingConversation(
id: json['id'] as int,
title: json['title'] as String? ?? '',
briefingDate: json['briefing_date'] as String?,
messages: rawMessages
.map((e) => Message.fromJson(e as Map<String, dynamic>))
.toList(),
);
}
BriefingConversation copyWith({List<Message>? messages}) =>
BriefingConversation(
id: id,
title: title,
briefingDate: briefingDate,
messages: messages ?? this.messages,
);
}
- Step 2: Create BriefingApi
Create lib/data/api/briefing_api.dart:
import 'package:dio/dio.dart';
import '../models/briefing_conversation.dart';
import '../models/message.dart';
import 'api_client.dart';
class BriefingApi {
final Dio _dio;
const BriefingApi(this._dio);
/// GET /api/briefing/conversations/today
/// Returns (or creates) today's briefing conversation with messages embedded.
Future<BriefingConversation> getToday() async {
try {
final response = await _dio.get('/api/briefing/conversations/today');
return BriefingConversation.fromJson(
response.data as Map<String, dynamic>);
} on DioException catch (e) {
throw dioToApp(e);
}
}
/// GET /api/briefing/conversations
/// Returns list of past briefing conversations (no messages embedded).
Future<List<BriefingConversation>> getHistory() async {
try {
final response = await _dio.get('/api/briefing/conversations');
final data = response.data as Map<String, dynamic>;
final list = data['conversations'] as List<dynamic>;
return list
.map((e) => BriefingConversation.fromJson(e as Map<String, dynamic>))
.toList();
} on DioException catch (e) {
throw dioToApp(e);
}
}
/// GET /api/briefing/conversations/<id>/messages
Future<List<Message>> getMessages(int convId) async {
try {
final response =
await _dio.get('/api/briefing/conversations/$convId/messages');
final data = response.data as Map<String, dynamic>;
final list = data['messages'] as List<dynamic>;
return list
.map((e) => Message.fromJson(e as Map<String, dynamic>))
.toList();
} on DioException catch (e) {
throw dioToApp(e);
}
}
/// POST /api/briefing/trigger body: {"slot": slot}
/// slot: "compilation" | "morning" | "midday" | "afternoon"
Future<void> triggerSlot(String slot) async {
try {
await _dio.post('/api/briefing/trigger', data: {'slot': slot});
} on DioException catch (e) {
throw dioToApp(e);
}
}
}
- Step 3: Register provider
In lib/providers/api_client_provider.dart, add after the milestonesRepositoryProvider block:
// Add import at the top of the file:
import '../data/api/briefing_api.dart';
// Add provider:
final briefingApiProvider = Provider<BriefingApi>((ref) {
return BriefingApi(ref.watch(dioProvider));
});
- Step 4: Verify the app still compiles
cd /home/bvandeusen/Nextcloud/Projects/fabled_app
flutter analyze --no-fatal-infos
Expected: no new errors.
- Step 5: Commit
git add lib/data/models/briefing_conversation.dart \
lib/data/api/briefing_api.dart \
lib/providers/api_client_provider.dart
git commit -m "feat: add BriefingApi and BriefingConversation model"
Task 2: BriefingNotifier (state management)
Files:
- Create:
lib/providers/briefing_provider.dart
The notifier owns today's briefing state. Replies use the existing /api/chat/ message + SSE + poll pattern (same as MessagesNotifier), because briefing conversations are regular chat conversations under the hood.
- Step 1: Create briefing_provider.dart
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../data/models/briefing_conversation.dart';
import '../data/models/message.dart';
import 'api_client_provider.dart';
/// Drives the loading indicator in BriefingScreen's AppBar reply area.
final isBriefingStreamingProvider = StateProvider<bool>((ref) => false);
final briefingProvider =
AsyncNotifierProvider<BriefingNotifier, BriefingConversation>(
BriefingNotifier.new);
class BriefingNotifier extends AsyncNotifier<BriefingConversation> {
@override
Future<BriefingConversation> build() async {
return ref.read(briefingApiProvider).getToday();
}
/// Trigger a briefing slot (e.g. "compilation") then reload.
Future<void> refresh(String slot) async {
await ref.read(briefingApiProvider).triggerSlot(slot);
// Force a full reload from the server.
ref.invalidateSelf();
await future; // wait for rebuild to complete
}
/// Send a reply to today's briefing conversation.
///
/// Mirrors MessagesNotifier.sendMessage():
/// 1. Optimistic UI update
/// 2. POST message to chat endpoint
/// 3. SSE stream (best-effort)
/// 4. Poll until complete
Future<void> sendReply(String content) async {
final conv = state.valueOrNull;
if (conv == null) return;
final convId = conv.id;
final chatApi = ref.read(chatApiProvider);
final previous = conv.messages;
final userMsg = Message(
conversationId: convId,
role: MessageRole.user,
content: content,
);
final placeholder = Message(
conversationId: convId,
role: MessageRole.assistant,
content: '',
status: 'generating',
);
state = AsyncData(conv.copyWith(messages: [...previous, userMsg, placeholder]));
ref.read(isBriefingStreamingProvider.notifier).state = true;
try {
await chatApi.sendMessage(convId, content);
} catch (e) {
state = AsyncData(conv.copyWith(messages: previous));
ref.read(isBriefingStreamingProvider.notifier).state = false;
rethrow;
}
// SSE stream (best-effort)
bool streamedContent = false;
try {
await for (final chunk in chatApi.streamGeneration(convId)) {
streamedContent = true;
final current = state.valueOrNull;
if (current == null) break;
final msgs = current.messages;
if (msgs.isEmpty) continue;
final updated = msgs.last.copyWith(content: msgs.last.content + chunk);
state = AsyncData(current.copyWith(
messages: [...msgs.sublist(0, msgs.length - 1), updated]));
}
} catch (_) {
// Fall through to polling.
}
// Poll until complete (max 20 attempts, 2s apart)
try {
for (var attempt = 0; attempt < 20; attempt++) {
if (attempt > 0) await Future.delayed(const Duration(seconds: 2));
final fresh = await ref.read(briefingApiProvider).getMessages(convId);
final done = fresh.any(
(m) => m.role == MessageRole.assistant && m.status != 'generating',
);
final hasContent = fresh.any(
(m) => m.role == MessageRole.assistant && m.content.isNotEmpty,
);
final current = state.valueOrNull;
if (current != null && (!streamedContent || done || hasContent)) {
state = AsyncData(current.copyWith(messages: fresh));
}
if (done) break;
}
} catch (_) {
// Clear the generating placeholder so UI doesn't spin forever.
final current = state.valueOrNull;
if (current != null) {
final msgs = current.messages;
if (msgs.isNotEmpty && msgs.last.status == 'generating') {
state = AsyncData(current.copyWith(messages: [
...msgs.sublist(0, msgs.length - 1),
msgs.last.copyWith(status: 'complete'),
]));
}
}
} finally {
ref.read(isBriefingStreamingProvider.notifier).state = false;
}
}
}
Note: chatApiProvider is already registered in api_client_provider.dart.
- Step 2: Verify compilation
flutter analyze --no-fatal-infos
Expected: no errors.
- Step 3: Commit
git add lib/providers/briefing_provider.dart
git commit -m "feat: add BriefingNotifier with sendReply and refresh"
Chunk 2: Widgets
Task 3: ChatMessageBubble (extracted, shared widget)
Files:
- Create:
lib/widgets/chat_message_bubble.dart - Modify:
lib/screens/chat/chat_screen.dart
Extract _MessageBubble from ChatScreen into a shared widget and apply the "Illuminated Transcript" design language from the spec:
-
User bubbles: transparent background, thin primary-colour border, muted text (ghost style)
-
Assistant bubbles: surface elevated, 2dp left accent border (indigo)
-
Step 1: Create chat_message_bubble.dart
import 'dart:math' show min;
import 'package:flutter/material.dart';
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
import '../data/models/message.dart';
class ChatMessageBubble extends StatelessWidget {
final Message message;
const ChatMessageBubble({super.key, required this.message});
@override
Widget build(BuildContext context) {
final isUser = message.role == MessageRole.user;
final scheme = Theme.of(context).colorScheme;
final isGenerating = message.status == 'generating';
return Align(
alignment: isUser ? Alignment.centerRight : Alignment.centerLeft,
child: Container(
constraints: BoxConstraints(
maxWidth: min(MediaQuery.of(context).size.width * 0.82, 480),
),
margin: const EdgeInsets.symmetric(vertical: 4, horizontal: 4),
decoration: isUser
? BoxDecoration(
// Ghost style: transparent bg, thin border
color: Colors.transparent,
border: Border.all(
color: scheme.primary.withOpacity(0.35),
width: 1,
),
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(16),
topRight: Radius.circular(16),
bottomLeft: Radius.circular(16),
bottomRight: Radius.circular(4),
),
)
: BoxDecoration(
// Assistant: elevated surface + left accent border
color: scheme.surfaceContainerHighest,
border: Border(
left: BorderSide(color: scheme.primary, width: 2),
),
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(4),
topRight: Radius.circular(16),
bottomLeft: Radius.circular(4),
bottomRight: Radius.circular(16),
),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: isGenerating && message.content.isEmpty
? SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: scheme.onSurfaceVariant,
),
)
: MarkdownBody(
data: message.content.isEmpty ? '…' : message.content,
styleSheet: MarkdownStyleSheet(
p: TextStyle(
color: isUser
? scheme.onSurface.withOpacity(0.75)
: scheme.onSurface,
fontSize: 14,
),
),
),
),
),
);
}
}
- Step 2: Replace
_MessageBubblein chat_screen.dart
In lib/screens/chat/chat_screen.dart:
Add import near the top (after existing imports):
import '../../widgets/chat_message_bubble.dart';
In the itemBuilder within the ListView, replace:
_MessageBubble(message: messages[i]),
with:
ChatMessageBubble(message: messages[i]),
Then delete the entire _MessageBubble class at the bottom of the file (lines starting with class _MessageBubble through to its closing }).
- Step 3: Verify compilation
flutter analyze --no-fatal-infos
Expected: no errors.
- Step 4: Commit
git add lib/widgets/chat_message_bubble.dart lib/screens/chat/chat_screen.dart
git commit -m "feat: extract ChatMessageBubble widget, apply design language"
Task 4: BriefingDigestCard widget
Files:
- Create:
lib/widgets/briefing_digest_card.dart
The DigestCard shows the first assistant message from the briefing conversation. It is truncated to 5 lines by default; tapping "Show more" expands with AnimatedSize. If no assistant message exists yet, a placeholder + "Generate now" button appears.
- Step 1: Create briefing_digest_card.dart
import 'package:flutter/material.dart';
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
import '../data/models/message.dart';
class BriefingDigestCard extends StatefulWidget {
/// The first assistant message from today's briefing, or null if none yet.
final Message? message;
/// Called when the user taps "Generate now".
final VoidCallback? onGenerateNow;
const BriefingDigestCard({
super.key,
required this.message,
this.onGenerateNow,
});
@override
State<BriefingDigestCard> createState() => _BriefingDigestCardState();
}
class _BriefingDigestCardState extends State<BriefingDigestCard> {
bool _expanded = false;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
return Card(
margin: const EdgeInsets.fromLTRB(12, 8, 12, 4),
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
side: BorderSide(
color: scheme.outlineVariant.withOpacity(0.5),
width: 1,
),
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header row
Row(
children: [
Icon(Icons.wb_sunny_outlined,
size: 18, color: scheme.primary),
const SizedBox(width: 8),
Text(
_todayLabel(),
style: textTheme.labelMedium?.copyWith(
color: scheme.onSurfaceVariant,
),
),
],
),
const SizedBox(height: 10),
// Body
if (widget.message == null) ...[
Text(
'No briefing yet today.',
style: textTheme.bodyMedium?.copyWith(
color: scheme.onSurfaceVariant,
),
),
const SizedBox(height: 12),
if (widget.onGenerateNow != null)
FilledButton.tonal(
onPressed: widget.onGenerateNow,
child: const Text('Generate now'),
),
] else ...[
AnimatedSize(
duration: const Duration(milliseconds: 250),
curve: Curves.easeInOut,
alignment: Alignment.topCenter,
child: _expanded
? MarkdownBody(data: widget.message!.content)
: _TruncatedMarkdown(
data: widget.message!.content,
maxLines: 5,
),
),
const SizedBox(height: 8),
GestureDetector(
onTap: () => setState(() => _expanded = !_expanded),
child: Text(
_expanded ? 'Show less ↑' : 'Show more ↓',
style: TextStyle(
color: scheme.primary,
fontSize: 13,
fontWeight: FontWeight.w500,
),
),
),
],
],
),
),
);
}
String _todayLabel() {
final now = DateTime.now();
const days = [
'Monday', 'Tuesday', 'Wednesday', 'Thursday',
'Friday', 'Saturday', 'Sunday'
];
const months = [
'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December'
];
return '${days[now.weekday - 1]}, ${months[now.month - 1]} ${now.day}';
}
}
/// Renders Markdown truncated to [maxLines] visible lines.
class _TruncatedMarkdown extends StatelessWidget {
final String data;
final int maxLines;
const _TruncatedMarkdown({required this.data, required this.maxLines});
@override
Widget build(BuildContext context) {
// Clamp to maxLines by wrapping in a constrained box with clip.
return LayoutBuilder(
builder: (context, constraints) {
return ConstrainedBox(
constraints: BoxConstraints(
maxHeight: maxLines * 20.0, // approximate line height
),
child: ClipRect(
child: MarkdownBody(data: data),
),
);
},
);
}
}
- Step 2: Verify compilation
flutter analyze --no-fatal-infos
Expected: no errors.
- Step 3: Commit
git add lib/widgets/briefing_digest_card.dart
git commit -m "feat: add BriefingDigestCard widget with expand/collapse"
Chunk 3: Screens
Task 5: BriefingHistoryScreen
Files:
- Create:
lib/screens/briefing/briefing_history_screen.dart
A simple read-only screen listing past briefing dates. Tapping a row shows the messages for that briefing (push a message-list screen inline). No reply bar.
- Step 1: Create briefing_history_screen.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../data/api/briefing_api.dart';
import '../../data/models/briefing_conversation.dart';
import '../../data/models/message.dart';
import '../../providers/api_client_provider.dart';
import '../../widgets/chat_message_bubble.dart';
class BriefingHistoryScreen extends ConsumerWidget {
const BriefingHistoryScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final historyAsync = ref.watch(_briefingHistoryProvider);
return Scaffold(
appBar: AppBar(title: const Text('Past Briefings')),
body: historyAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (_, _) =>
const Center(child: Text('Could not load briefing history.')),
data: (convs) {
if (convs.isEmpty) {
return const Center(child: Text('No past briefings.'));
}
return ListView.builder(
itemCount: convs.length,
itemBuilder: (context, i) {
final conv = convs[i];
final label = conv.briefingDate ?? conv.title;
return ListTile(
leading: const Icon(Icons.wb_sunny_outlined),
title: Text(label),
trailing: const Icon(Icons.chevron_right),
onTap: () => Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => _BriefingDetailScreen(conv: conv),
),
),
);
},
);
},
),
);
}
}
/// Lazily loads and displays all messages for a past briefing.
class _BriefingDetailScreen extends ConsumerWidget {
final BriefingConversation conv;
const _BriefingDetailScreen({required this.conv});
@override
Widget build(BuildContext context, WidgetRef ref) {
final messagesAsync =
ref.watch(_briefingMessagesProvider(conv.id));
return Scaffold(
appBar: AppBar(
title: Text(conv.briefingDate ?? conv.title),
),
body: messagesAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (_, _) =>
const Center(child: Text('Could not load messages.')),
data: (messages) {
if (messages.isEmpty) {
return const Center(child: Text('No messages.'));
}
return ListView.builder(
padding:
const EdgeInsets.symmetric(horizontal: 8, vertical: 12),
itemCount: messages.length,
itemBuilder: (_, i) =>
ChatMessageBubble(message: messages[i]),
);
},
),
);
}
}
// ── Private providers (scoped to this file) ──────────────────────────────────
final _briefingHistoryProvider =
FutureProvider<List<BriefingConversation>>((ref) async {
return ref.watch(briefingApiProvider).getHistory();
});
final _briefingMessagesProvider =
FutureProvider.family<List<Message>, int>((ref, convId) async {
return ref.watch(briefingApiProvider).getMessages(convId);
});
- Step 2: Verify compilation
flutter analyze --no-fatal-infos
Expected: no errors.
- Step 3: Commit
git add lib/screens/briefing/briefing_history_screen.dart
git commit -m "feat: add BriefingHistoryScreen (read-only past briefings)"
Task 6: BriefingScreen (full implementation)
Files:
- Modify (replace):
lib/screens/briefing/briefing_screen.dart
This replaces the placeholder created in Plan 2 with the full implementation. The layout from the spec:
AppBar:
title: "Briefing" (uses Fraunces via theme)
subtitle: today's date string
actions: [CircularProgressIndicator | ↻ refresh, ⋯ overflow menu]
Body (Column):
BriefingDigestCard (first assistant message, expandable)
Divider + "Conversation" label
Expanded ListView (ChatMessageBubble for messages[1..])
[streaming bubble at bottom if generating]
Bottom pinned:
SafeArea → reply bar (TextField + GradientButton send)
LinearProgressIndicator (2dp, indigo) when streaming
The DigestCard shows messages.firstWhere(role == assistant). The conversation list shows all messages except the first assistant message (which is in the card) — so index 0 if it's user, or skip the first assistant message.
Simpler approach: show ALL messages in the list, and also show the first assistant message in the DigestCard. The DigestCard serves as a "header" summary, not a replacement for the message in the list. This avoids complicated index math and keeps the conversation complete.
- Step 1: Write briefing_screen.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/exceptions.dart';
import '../../data/models/message.dart';
import '../../providers/briefing_provider.dart';
import '../../widgets/briefing_digest_card.dart';
import '../../widgets/chat_message_bubble.dart';
import '../briefing/briefing_history_screen.dart';
class BriefingScreen extends ConsumerStatefulWidget {
const BriefingScreen({super.key});
@override
ConsumerState<BriefingScreen> createState() => _BriefingScreenState();
}
class _BriefingScreenState extends ConsumerState<BriefingScreen> {
final _controller = TextEditingController();
final _scrollController = ScrollController();
bool _refreshing = false;
@override
void dispose() {
_controller.dispose();
_scrollController.dispose();
super.dispose();
}
void _scrollToBottom() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_scrollController.hasClients) {
_scrollController.animateTo(
_scrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: 200),
curve: Curves.easeOut,
);
}
});
}
Future<void> _sendReply() async {
final text = _controller.text.trim();
if (text.isEmpty) return;
_controller.clear();
try {
await ref.read(briefingProvider.notifier).sendReply(text);
} on AppException catch (e) {
if (mounted) {
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(e.message)));
}
} catch (_) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Failed to send reply.')),
);
}
}
}
Future<void> _refresh() async {
setState(() => _refreshing = true);
try {
await ref.read(briefingProvider.notifier).refresh('compilation');
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Could not generate briefing.')),
);
}
} finally {
if (mounted) setState(() => _refreshing = false);
}
}
@override
Widget build(BuildContext context) {
final briefingAsync = ref.watch(briefingProvider);
final isStreaming = ref.watch(isBriefingStreamingProvider);
final scheme = Theme.of(context).colorScheme;
// Scroll to bottom when messages change
ref.listen(briefingProvider, (_, _) => _scrollToBottom());
return Scaffold(
appBar: AppBar(
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Briefing'),
Text(
_todayLabel(),
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: scheme.onSurfaceVariant,
),
),
],
),
actions: [
// Refresh button — shows spinner when refreshing
if (_refreshing)
const Padding(
padding: EdgeInsets.symmetric(horizontal: 12),
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
)
else
IconButton(
icon: const Icon(Icons.refresh_outlined),
tooltip: 'Generate briefing',
onPressed: _refresh,
),
PopupMenuButton<String>(
onSelected: (value) {
if (value == 'history') {
Navigator.of(context).push(MaterialPageRoute(
builder: (_) => const BriefingHistoryScreen(),
));
}
},
itemBuilder: (_) => const [
PopupMenuItem(
value: 'history',
child: Text('View past briefings'),
),
],
),
],
),
body: briefingAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (_, _) => Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text('Could not load today\'s briefing.'),
const SizedBox(height: 12),
FilledButton.tonal(
onPressed: () => ref.invalidate(briefingProvider),
child: const Text('Retry'),
),
],
),
),
data: (conv) {
// First assistant message for the digest card (null if none yet)
final Message? firstAssistant = conv.messages
.where((m) => m.role == MessageRole.assistant)
.firstOrNull;
return Column(
children: [
// Digest card header
BriefingDigestCard(
message: firstAssistant,
onGenerateNow: _refresh,
),
// Divider + "Conversation" label
if (conv.messages.isNotEmpty) ...[
const SizedBox(height: 4),
Row(children: [
const Expanded(child: Divider()),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 10),
child: Text(
'Conversation',
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: scheme.onSurfaceVariant,
),
),
),
const Expanded(child: Divider()),
]),
],
// Message list
Expanded(
child: ListView.builder(
controller: _scrollController,
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 8),
itemCount: conv.messages.length,
itemBuilder: (_, i) =>
ChatMessageBubble(message: conv.messages[i]),
),
),
// Progress bar while streaming
if (isStreaming)
LinearProgressIndicator(
minHeight: 2,
color: scheme.primary,
),
// Reply bar
const Divider(height: 1),
SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(8, 6, 8, 6),
child: Row(
children: [
Expanded(
child: TextField(
controller: _controller,
decoration: const InputDecoration(
hintText: 'Reply to your briefing…',
border: OutlineInputBorder(),
isDense: true,
contentPadding: EdgeInsets.symmetric(
horizontal: 12, vertical: 10),
),
minLines: 1,
maxLines: 4,
textInputAction: TextInputAction.newline,
enabled: !isStreaming,
),
),
const SizedBox(width: 8),
// GradientButton send — from Plan 1 theme
_GradientSendButton(
onPressed: isStreaming ? null : _sendReply,
isStreaming: isStreaming,
),
],
),
),
),
],
);
},
),
);
}
String _todayLabel() {
final now = DateTime.now();
const days = [
'Monday', 'Tuesday', 'Wednesday', 'Thursday',
'Friday', 'Saturday', 'Sunday'
];
const months = [
'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December'
];
return '${days[now.weekday - 1]}, ${months[now.month - 1]} ${now.day}';
}
}
/// Send button with the indigo gradient from the design spec.
/// Uses a simple DecoratedBox + InkWell to keep it dependency-free.
class _GradientSendButton extends StatelessWidget {
final VoidCallback? onPressed;
final bool isStreaming;
const _GradientSendButton({
required this.onPressed,
required this.isStreaming,
});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final disabled = onPressed == null;
return DecoratedBox(
decoration: BoxDecoration(
gradient: disabled
? null
: const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFF6366F1), Color(0xFF4F46E5)],
),
color: disabled ? scheme.onSurface.withOpacity(0.12) : null,
borderRadius: BorderRadius.circular(10),
),
child: IconButton(
icon: isStreaming
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: Icon(
Icons.send,
color: disabled ? scheme.onSurface.withOpacity(0.38) : Colors.white,
),
onPressed: onPressed,
),
);
}
}
- Step 2: Verify compilation
flutter analyze --no-fatal-infos
Expected: no errors. If firstOrNull is not available on the filtered Iterable, wrap with .toList() first: conv.messages.where(...).toList().firstOrNull.
- Step 3: Verify on device / emulator (manual)
- App opens → Briefing tab shown by default
- If server has today's briefing: DigestCard shows first assistant message
- "Show more ↓" expands card; "Show less ↑" collapses
- If no briefing: placeholder + "Generate now" appears
- Tapping ↻ calls trigger slot, shows progress, reloads
- Type reply → tap send → user bubble appears, generating spinner, then assistant reply
- ⋯ menu → "View past briefings" → pushes BriefingHistoryScreen
- BriefingHistory lists past dates → tap one → read-only message list
- Step 4: Commit
git add lib/screens/briefing/briefing_screen.dart
git commit -m "feat: implement full BriefingScreen (digest card, reply bar, history nav)"
Chunk 4: Final Wiring
Task 7: Final compile check + push
- Step 1: Full analyze pass
cd /home/bvandeusen/Nextcloud/Projects/fabled_app
flutter analyze --no-fatal-infos 2>&1 | tail -30
Expected: "No issues found!" or only pre-existing warnings unrelated to this plan.
- Step 2: Verify the full file list
ls lib/data/models/briefing_conversation.dart \
lib/data/api/briefing_api.dart \
lib/providers/briefing_provider.dart \
lib/widgets/chat_message_bubble.dart \
lib/widgets/briefing_digest_card.dart \
lib/screens/briefing/briefing_screen.dart \
lib/screens/briefing/briefing_history_screen.dart
Expected: all 7 files present.
- Step 3: Commit summary tag
git tag p3-complete
git log --oneline -8
Expected: 6 commits from this plan visible in the log.
Verification Checklist (all three plans applied)
flutter analyze— clean build- App launches → opens on Briefing tab (not Notes or Chat)
- Theme matches: dark =
#111113background,#6366F1primary; Fraunces headings - Quick Capture bar: type, submit, immediately type again — second item queues while first is in-flight; snackbar fires per completion
- Library tab: All / Notes / Tasks / Projects filter pills; search icon slides in search bar
- Chat tab: tapping a conversation opens ChatScreen
- Briefing screen: digest card, reply sends, history navigates
- Old list screens gone:
/notes-list,/tasks-list,/projects-listroutes removed