From 3422caebfc18bcf29acdd74102697533ebc763b3 Mon Sep 17 00:00:00 2001 From: bvandeusen Date: Wed, 11 Mar 2026 22:52:19 -0400 Subject: [PATCH] =?UTF-8?q?docs:=20add=20Plan=203=20=E2=80=94=20BriefingSc?= =?UTF-8?q?reen=20implementation=20plan?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2026-03-11-app-overhaul-p3-briefing.md | 1173 +++++++++++++++++ 1 file changed, 1173 insertions(+) create mode 100644 docs/superpowers/plans/2026-03-11-app-overhaul-p3-briefing.md diff --git a/docs/superpowers/plans/2026-03-11-app-overhaul-p3-briefing.md b/docs/superpowers/plans/2026-03-11-app-overhaul-p3-briefing.md new file mode 100644 index 0000000..6506f8f --- /dev/null +++ b/docs/superpowers/plans/2026-03-11-app-overhaul-p3-briefing.md @@ -0,0 +1,1173 @@ +# 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