1174 lines
37 KiB
Markdown
1174 lines
37 KiB
Markdown
# 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<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`:
|
|
|
|
```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:
|
|
|
|
```dart
|
|
// 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**
|
|
|
|
```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<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**
|
|
|
|
```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<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**
|
|
|
|
```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<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**
|
|
|
|
```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<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**
|
|
|
|
```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
|