# 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` — `BriefingConversation` model - `lib/data/api/briefing_api.dart` — `BriefingApi` (getToday, getHistory, getMessages, triggerSlot) - `lib/providers/briefing_provider.dart` — `BriefingNotifier`, `isBriefingStreamingProvider` - `lib/widgets/chat_message_bubble.dart` — extracted + styled bubble widget (shared by Chat + Briefing) - `lib/widgets/briefing_digest_card.dart` — expandable first-message card - `lib/screens/briefing/briefing_history_screen.dart` — read-only past briefings list - `lib/screens/briefing/briefing_screen.dart` — full implementation (replaces Plan 2 placeholder) **Modify:** - `lib/providers/api_client_provider.dart` — add `briefingApiProvider` - `lib/screens/chat/chat_screen.dart` — replace `_MessageBubble` with `ChatMessageBubble` --- ## 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`: ```dart import 'message.dart'; class BriefingConversation { final int id; final String title; final String? briefingDate; // YYYY-MM-DD or null final List messages; const BriefingConversation({ required this.id, required this.title, this.briefingDate, required this.messages, }); factory BriefingConversation.fromJson(Map json) { final rawMessages = json['messages'] as List? ?? []; 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)) .toList(), ); } BriefingConversation copyWith({List? messages}) => BriefingConversation( id: id, title: title, briefingDate: briefingDate, messages: messages ?? this.messages, ); } ``` - [ ] **Step 2: Create BriefingApi** Create `lib/data/api/briefing_api.dart`: ```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 getToday() async { try { final response = await _dio.get('/api/briefing/conversations/today'); return BriefingConversation.fromJson( response.data as Map); } on DioException catch (e) { throw dioToApp(e); } } /// GET /api/briefing/conversations /// Returns list of past briefing conversations (no messages embedded). Future> getHistory() async { try { final response = await _dio.get('/api/briefing/conversations'); final data = response.data as Map; final list = data['conversations'] as List; return list .map((e) => BriefingConversation.fromJson(e as Map)) .toList(); } on DioException catch (e) { throw dioToApp(e); } } /// GET /api/briefing/conversations//messages Future> getMessages(int convId) async { try { final response = await _dio.get('/api/briefing/conversations/$convId/messages'); final data = response.data as Map; final list = data['messages'] as List; return list .map((e) => Message.fromJson(e as Map)) .toList(); } on DioException catch (e) { throw dioToApp(e); } } /// POST /api/briefing/trigger body: {"slot": slot} /// slot: "compilation" | "morning" | "midday" | "afternoon" Future 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: ```dart // Add import at the top of the file: import '../data/api/briefing_api.dart'; // Add provider: final briefingApiProvider = Provider((ref) { return BriefingApi(ref.watch(dioProvider)); }); ``` - [ ] **Step 4: Verify the app still compiles** ```bash cd /home/bvandeusen/Nextcloud/Projects/fabled_app flutter analyze --no-fatal-infos ``` Expected: no new errors. - [ ] **Step 5: Commit** ```bash 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** ```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((ref) => false); final briefingProvider = AsyncNotifierProvider( BriefingNotifier.new); class BriefingNotifier extends AsyncNotifier { @override Future build() async { return ref.read(briefingApiProvider).getToday(); } /// Trigger a briefing slot (e.g. "compilation") then reload. Future 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 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** ```bash flutter analyze --no-fatal-infos ``` Expected: no errors. - [ ] **Step 3: Commit** ```bash 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** ```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 `_MessageBubble` in chat_screen.dart** In `lib/screens/chat/chat_screen.dart`: Add import near the top (after existing imports): ```dart import '../../widgets/chat_message_bubble.dart'; ``` In the `itemBuilder` within the ListView, replace: ```dart _MessageBubble(message: messages[i]), ``` with: ```dart 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** ```bash flutter analyze --no-fatal-infos ``` Expected: no errors. - [ ] **Step 4: Commit** ```bash 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** ```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 createState() => _BriefingDigestCardState(); } class _BriefingDigestCardState extends State { 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** ```bash flutter analyze --no-fatal-infos ``` Expected: no errors. - [ ] **Step 3: Commit** ```bash 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** ```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>((ref) async { return ref.watch(briefingApiProvider).getHistory(); }); final _briefingMessagesProvider = FutureProvider.family, int>((ref, convId) async { return ref.watch(briefingApiProvider).getMessages(convId); }); ``` - [ ] **Step 2: Verify compilation** ```bash flutter analyze --no-fatal-infos ``` Expected: no errors. - [ ] **Step 3: Commit** ```bash 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** ```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 createState() => _BriefingScreenState(); } class _BriefingScreenState extends ConsumerState { 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 _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 _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( 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** ```bash 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** ```bash 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** ```bash 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** ```bash 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** ```bash git tag p3-complete git log --oneline -8 ``` Expected: 6 commits from this plan visible in the log. --- ## Verification Checklist (all three plans applied) 1. `flutter analyze` — clean build 2. App launches → opens on **Briefing** tab (not Notes or Chat) 3. Theme matches: dark = `#111113` background, `#6366F1` primary; Fraunces headings 4. Quick Capture bar: type, submit, immediately type again — second item queues while first is in-flight; snackbar fires per completion 5. Library tab: All / Notes / Tasks / Projects filter pills; search icon slides in search bar 6. Chat tab: tapping a conversation opens ChatScreen 7. Briefing screen: digest card, reply sends, history navigates 8. Old list screens gone: `/notes-list`, `/tasks-list`, `/projects-list` routes removed