This repository has been archived on 2026-06-02. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
FabledApp/docs/superpowers/plans/2026-03-11-app-overhaul-p3-briefing.md
T

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.dartBriefingConversation model
  • lib/data/api/briefing_api.dartBriefingApi (getToday, getHistory, getMessages, triggerSlot)
  • lib/providers/briefing_provider.dartBriefingNotifier, 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:

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 _MessageBubble in 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)

  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