feat: app overhaul — Briefing-first navigation, Library, capture queue

Plans 1-3 implemented:

Plan 1 — Foundation
- Add google_fonts ^6.2.1
- lib/core/theme.dart: slate-indigo ColorScheme (dark/light), Fraunces
  headings, GradientButton widget
- lib/providers/capture_work_queue_provider.dart: in-memory sequential
  work queue; CaptureWorkQueueNotifier drains one item at a time;
  captureResultProvider feeds snackbars to UI
- lib/app.dart: wire fabledDarkTheme/fabledLightTheme; replace blocking
  _QuickCaptureBar with queue-based implementation (progress bar, badge)

Plan 2 — Navigation
- 3-tab shell: Briefing · Library · Chat (was Notes · Tasks · Projects · Chat)
- lib/screens/library/library_screen.dart: unified notes+tasks+projects list
  with filter pills, status sub-filter for tasks, live search, FAB
- lib/widgets/library_item_card.dart: NoteLibraryCard, TaskLibraryCard
  (status cycle), ProjectLibraryCard
- lib/screens/chat/conversations_tab_screen.dart: focused replacement for
  ConversationsListScreen
- Delete 5 dead screens: notes_list, tasks_list, project_list,
  conversations_list, quick_capture

Plan 3 — Briefing
- lib/data/models/briefing_conversation.dart
- lib/data/api/briefing_api.dart: getToday, getHistory, getMessages,
  triggerSlot
- lib/providers/briefing_provider.dart: BriefingNotifier with sendReply
  (optimistic + SSE + poll, same pattern as MessagesNotifier) and refresh
- lib/widgets/chat_message_bubble.dart: extracted + redesigned shared bubble
  (ghost user bubbles, left-accent assistant bubbles)
- lib/widgets/briefing_digest_card.dart: expandable first-message card
- lib/screens/briefing/briefing_screen.dart: digest card, conversation list,
  streaming reply bar, refresh button, history overflow menu
- lib/screens/briefing/briefing_history_screen.dart: read-only past dates

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-11 23:17:38 -04:00
parent 3422caebfc
commit 6232c7c99a
25 changed files with 3830 additions and 1593 deletions
@@ -0,0 +1,720 @@
# Fabled App Overhaul — Plan 1: Foundation (Theme + Capture Queue)
> **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:** Apply the main app's slate-indigo palette and Fraunces typography to the Flutter app, and replace the blocking quick-capture input with a multi-item sequential work queue.
**Architecture:** A new `lib/core/theme.dart` defines both light and dark `ThemeData` with custom `ColorScheme` and Fraunces headings via `google_fonts`. The capture bar in `app.dart` delegates to a new `CaptureWorkQueueNotifier` (in-memory queue, drains sequentially) instead of blocking on each request. A `_captureResultProvider` carries per-item outcomes to the bar for snackbar display.
**Tech Stack:** Flutter/Dart, Riverpod, google_fonts package.
---
## File Map
**Create:**
- `lib/core/theme.dart` — light + dark `ThemeData`, `GradientButton` widget
- `lib/providers/capture_work_queue_provider.dart` — in-memory work queue notifier + result provider
**Modify:**
- `pubspec.yaml` — add `google_fonts: ^6.2.1`
- `lib/main.dart` — import and use `fabledTheme` / `fabledDarkTheme`
- `lib/app.dart` — update `_QuickCaptureBar` to use work queue
---
## Chunk 1: Theme
### Task 1: Add google_fonts dependency
**Files:**
- Modify: `pubspec.yaml`
- [ ] **Step 1: Add dependency**
In the `dependencies:` section, after `flutter_markdown_plus`, add:
```yaml
google_fonts: ^6.2.1
```
- [ ] **Step 2: Verify it resolves**
```bash
cd /home/bvandeusen/Nextcloud/Projects/fabled_app
flutter pub get
```
Expected: exits 0, no version conflicts.
- [ ] **Step 3: Commit**
```bash
git add pubspec.yaml pubspec.lock
git commit -m "feat: add google_fonts dependency"
```
---
### Task 2: Create theme.dart
**Files:**
- Create: `lib/core/theme.dart`
- [ ] **Step 1: Create `lib/core/theme.dart`**
```dart
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
// ── Colour constants ──────────────────────────────────────────────────────────
const _darkBackground = Color(0xFF111113);
const _darkSurface = Color(0xFF18181C);
const _darkSurfaceVar = Color(0xFF1E1E24);
const _darkPrimary = Color(0xFF6366F1);
const _darkOnSurface = Color(0xFFE8E8F0);
const _darkOnSurfaceVar = Color(0xFF8888A8);
const _darkOutline = Color(0xFF2E2E3A);
const _lightBackground = Color(0xFFF4F4F8);
const _lightSurface = Color(0xFFFFFFFF);
const _lightSurfaceVar = Color(0xFFF0F0F5);
const _lightPrimary = Color(0xFF4F46E5);
const _lightOnSurface = Color(0xFF18181C);
const _lightOnSurfaceVar = Color(0xFF6B6B88);
const _lightOutline = Color(0xFFD4D4E4);
// ── Typography ─────────────────────────────────────────────────────────────────
TextTheme _buildTextTheme(TextTheme base) {
final fraunces = GoogleFonts.frauncesTextTheme(base);
return base.copyWith(
// Headings / titles use Fraunces
headlineLarge: fraunces.headlineLarge,
headlineMedium: fraunces.headlineMedium,
headlineSmall: fraunces.headlineSmall,
titleLarge: fraunces.titleLarge,
titleMedium: fraunces.titleMedium,
// Body / labels remain system default
);
}
// ── Themes ─────────────────────────────────────────────────────────────────────
ThemeData fabledDarkTheme() {
final cs = ColorScheme(
brightness: Brightness.dark,
primary: _darkPrimary,
onPrimary: Colors.white,
primaryContainer: const Color(0xFF3730A3),
onPrimaryContainer: _darkOnSurface,
secondary: _darkPrimary,
onSecondary: Colors.white,
secondaryContainer: _darkSurfaceVar,
onSecondaryContainer: _darkOnSurface,
tertiary: _darkPrimary,
onTertiary: Colors.white,
tertiaryContainer: _darkSurfaceVar,
onTertiaryContainer: _darkOnSurface,
error: const Color(0xFFEF4444),
onError: Colors.white,
errorContainer: const Color(0xFF7F1D1D),
onErrorContainer: const Color(0xFFFEE2E2),
surface: _darkSurface,
onSurface: _darkOnSurface,
surfaceContainerHighest: _darkSurfaceVar,
onSurfaceVariant: _darkOnSurfaceVar,
outline: _darkOutline,
outlineVariant: _darkOutline,
shadow: Colors.black,
scrim: Colors.black,
inverseSurface: _darkOnSurface,
onInverseSurface: _darkSurface,
inversePrimary: _lightPrimary,
);
return ThemeData(
useMaterial3: true,
colorScheme: cs,
scaffoldBackgroundColor: _darkBackground,
textTheme: _buildTextTheme(ThemeData.dark().textTheme),
cardTheme: CardTheme(
color: _darkSurface,
elevation: 2,
shadowColor: Colors.black.withValues(alpha: 0.4),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
),
navigationBarTheme: NavigationBarThemeData(
backgroundColor: _darkSurface,
indicatorColor: _darkPrimary.withValues(alpha: 0.2),
),
navigationRailTheme: NavigationRailThemeData(
backgroundColor: _darkSurface,
indicatorColor: _darkPrimary.withValues(alpha: 0.2),
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: _darkSurfaceVar,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(24),
borderSide: BorderSide(color: _darkOutline),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(24),
borderSide: BorderSide(color: _darkOutline),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(24),
borderSide: BorderSide(color: _darkPrimary, width: 2),
),
),
dividerTheme: DividerThemeData(color: _darkOutline, thickness: 1),
chipTheme: ChipThemeData(
backgroundColor: _darkSurfaceVar,
labelStyle: TextStyle(color: _darkOnSurfaceVar, fontSize: 12),
side: BorderSide(color: _darkOutline),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
);
}
ThemeData fabledLightTheme() {
final cs = ColorScheme(
brightness: Brightness.light,
primary: _lightPrimary,
onPrimary: Colors.white,
primaryContainer: const Color(0xFFE0E0FF),
onPrimaryContainer: _lightOnSurface,
secondary: _lightPrimary,
onSecondary: Colors.white,
secondaryContainer: _lightSurfaceVar,
onSecondaryContainer: _lightOnSurface,
tertiary: _lightPrimary,
onTertiary: Colors.white,
tertiaryContainer: _lightSurfaceVar,
onTertiaryContainer: _lightOnSurface,
error: const Color(0xFFDC2626),
onError: Colors.white,
errorContainer: const Color(0xFFFEE2E2),
onErrorContainer: const Color(0xFF7F1D1D),
surface: _lightSurface,
onSurface: _lightOnSurface,
surfaceContainerHighest: _lightSurfaceVar,
onSurfaceVariant: _lightOnSurfaceVar,
outline: _lightOutline,
outlineVariant: _lightOutline,
shadow: Colors.black,
scrim: Colors.black,
inverseSurface: _lightOnSurface,
onInverseSurface: _lightSurface,
inversePrimary: _darkPrimary,
);
return ThemeData(
useMaterial3: true,
colorScheme: cs,
scaffoldBackgroundColor: _lightBackground,
textTheme: _buildTextTheme(ThemeData.light().textTheme),
cardTheme: CardTheme(
color: _lightSurface,
elevation: 1,
shadowColor: Colors.black.withValues(alpha: 0.08),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
),
navigationBarTheme: NavigationBarThemeData(
backgroundColor: _lightSurface,
indicatorColor: _lightPrimary.withValues(alpha: 0.12),
),
navigationRailTheme: NavigationRailThemeData(
backgroundColor: _lightSurface,
indicatorColor: _lightPrimary.withValues(alpha: 0.12),
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: _lightSurfaceVar,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(24),
borderSide: BorderSide(color: _lightOutline),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(24),
borderSide: BorderSide(color: _lightOutline),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(24),
borderSide: BorderSide(color: _lightPrimary, width: 2),
),
),
dividerTheme: DividerThemeData(color: _lightOutline, thickness: 1),
chipTheme: ChipThemeData(
backgroundColor: _lightSurfaceVar,
labelStyle: TextStyle(color: _lightOnSurfaceVar, fontSize: 12),
side: BorderSide(color: _lightOutline),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
);
}
// ── GradientButton ─────────────────────────────────────────────────────────────
// Use wherever the web app uses the indigo gradient button (send, primary actions).
class GradientButton extends StatelessWidget {
final VoidCallback? onPressed;
final Widget child;
final EdgeInsetsGeometry padding;
const GradientButton({
super.key,
required this.onPressed,
required this.child,
this.padding = const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
});
@override
Widget build(BuildContext context) {
final disabled = onPressed == null;
return AnimatedOpacity(
opacity: disabled ? 0.45 : 1.0,
duration: const Duration(milliseconds: 150),
child: DecoratedBox(
decoration: BoxDecoration(
gradient: disabled
? null
: const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFF6366F1), Color(0xFF4F46E5)],
),
color: disabled ? const Color(0xFF6366F1) : null,
borderRadius: BorderRadius.circular(12),
boxShadow: disabled
? null
: [
BoxShadow(
color: const Color(0xFF6366F1).withValues(alpha: 0.35),
blurRadius: 8,
offset: const Offset(0, 3),
),
],
),
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: onPressed,
borderRadius: BorderRadius.circular(12),
child: Padding(padding: padding, child: child),
),
),
),
);
}
}
```
- [ ] **Step 2: Verify it compiles**
```bash
cd /home/bvandeusen/Nextcloud/Projects/fabled_app
flutter analyze lib/core/theme.dart
```
Expected: no errors (warnings about deprecated APIs are OK).
- [ ] **Step 3: Commit**
```bash
git add lib/core/theme.dart
git commit -m "feat: custom slate-indigo theme with Fraunces typography"
```
---
### Task 3: Wire theme into the app
**Files:**
- Modify: `lib/main.dart` (import theme)
- Modify: `lib/app.dart` (FabledApp widget uses new themes)
- [ ] **Step 1: Read `lib/app.dart` lines 498523 (the `FabledApp` widget)**
Locate the `FabledApp.build()` method. It currently has:
```dart
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
useMaterial3: true,
),
darkTheme: ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.indigo,
brightness: Brightness.dark,
),
useMaterial3: true,
),
```
- [ ] **Step 2: Replace with custom themes**
Add import at the top of `lib/app.dart`:
```dart
import 'core/theme.dart';
```
Replace the `theme:` and `darkTheme:` arguments:
```dart
theme: fabledLightTheme(),
darkTheme: fabledDarkTheme(),
```
Remove the now-unused `import 'package:flutter/material.dart'` reference to `Colors.indigo` (keep the `material.dart` import itself).
- [ ] **Step 3: Run the app and visually verify**
```bash
flutter run --debug
```
Expected: app launches with dark slate-indigo background, indigo navigation bar, Fraunces headings visible on any screen that uses `titleLarge` or `headlineMedium`. No runtime errors.
- [ ] **Step 4: Commit**
```bash
git add lib/app.dart
git commit -m "feat: wire fabledLightTheme/fabledDarkTheme into MaterialApp"
```
---
## Chunk 2: Capture Work Queue
### Task 4: Create CaptureWorkQueueNotifier
**Files:**
- Create: `lib/providers/capture_work_queue_provider.dart`
This provider manages an in-memory FIFO queue of capture texts. A single async drain loop processes them sequentially. On success it publishes a result via `captureResultProvider` so the UI can show a snackbar. On `NetworkException` it falls through to the offline `captureQueueProvider`.
- [ ] **Step 1: Create `lib/providers/capture_work_queue_provider.dart`**
```dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../core/exceptions.dart';
import '../data/api/quick_capture_api.dart';
import 'api_client_provider.dart';
import 'capture_queue_provider.dart';
import 'notes_provider.dart';
import 'tasks_provider.dart';
/// Outcome of a single capture attempt — consumed by the UI for snackbars.
class CaptureResult {
final String message;
final bool isError;
const CaptureResult(this.message, {this.isError = false});
}
/// The most recent capture result. UI watches this to show snackbars.
/// Reset to null by the notifier before each new item so listeners always fire.
final captureResultProvider = StateProvider<CaptureResult?>((_) => null);
/// In-memory sequential work queue for quick captures.
/// Separate from [captureQueueProvider] (which is the offline persistence queue).
final captureWorkQueueProvider =
StateNotifierProvider<CaptureWorkQueueNotifier, List<String>>(
(ref) => CaptureWorkQueueNotifier(ref),
);
class CaptureWorkQueueNotifier extends StateNotifier<List<String>> {
final Ref _ref;
bool _running = false;
CaptureWorkQueueNotifier(this._ref) : super([]);
/// Add text to the queue and start the drain loop if not already running.
void enqueue(String text) {
state = [...state, text];
_drain();
}
Future<void> _drain() async {
if (_running) return;
_running = true;
try {
while (state.isNotEmpty) {
final text = state.first;
// Signal "no result yet" so the same result value can re-trigger watch.
_ref.read(captureResultProvider.notifier).state = null;
try {
final api = _ref.read(quickCaptureApiProvider);
final result = await api.capture(text);
// Dequeue on success.
state = state.length > 1 ? state.sublist(1) : [];
// Invalidate content providers so lists refresh.
switch (result.type) {
case 'note':
_ref.invalidate(notesProvider);
case 'task':
case 'todo':
_ref.invalidate(tasksProvider);
}
// Publish result for snackbar.
final msg = result.message.isNotEmpty
? result.message
: '${_typeLabel(result.type)} created: ${result.title}';
_ref.read(captureResultProvider.notifier).state =
CaptureResult(msg);
} on NetworkException catch (_) {
// Persist to offline queue and stop draining — still offline.
await _ref.read(captureQueueProvider.notifier).enqueue(text);
state = state.length > 1 ? state.sublist(1) : [];
_ref.read(captureResultProvider.notifier).state = CaptureResult(
"You're offline — capture saved and will retry automatically.",
isError: false,
);
break;
} on AppException catch (e) {
state = state.length > 1 ? state.sublist(1) : [];
_ref.read(captureResultProvider.notifier).state =
CaptureResult(e.message, isError: true);
} catch (_) {
state = state.length > 1 ? state.sublist(1) : [];
_ref.read(captureResultProvider.notifier).state =
CaptureResult('Capture failed. Please try again.', isError: true);
}
}
} finally {
_running = false;
}
}
String _typeLabel(String type) => switch (type) {
'note' => 'Note',
'task' => 'Task',
'event' => 'Event',
'todo' => 'To-do',
_ => type,
};
}
```
- [ ] **Step 2: Analyze for errors**
```bash
flutter analyze lib/providers/capture_work_queue_provider.dart
```
Expected: no errors.
- [ ] **Step 3: Commit**
```bash
git add lib/providers/capture_work_queue_provider.dart
git commit -m "feat: CaptureWorkQueueNotifier — sequential multi-item capture queue"
```
---
### Task 5: Update _QuickCaptureBar to use the work queue
**Files:**
- Modify: `lib/app.dart``_QuickCaptureBarState`
The bar currently calls `ref.read(quickCaptureApiProvider).capture(text)` directly and sets `_busy`. Replace with: enqueue to work queue, watch queue depth for badge, watch `captureResultProvider` for snackbars, show progress bar when queue is non-empty.
- [ ] **Step 1: Add imports to `lib/app.dart`**
Add at the top alongside existing imports:
```dart
import 'providers/capture_work_queue_provider.dart';
```
- [ ] **Step 2: Replace `_QuickCaptureBarState` completely**
Replace the entire `_QuickCaptureBarState` class (from `class _QuickCaptureBarState` through its closing `}`) with:
```dart
class _QuickCaptureBarState extends ConsumerState<_QuickCaptureBar> {
final _controller = TextEditingController();
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) => _drainOfflineQueue());
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _submit() {
final text = _controller.text.trim();
if (text.isEmpty) return;
_controller.clear();
setState(() {}); // clear suffix icon
ref.read(captureWorkQueueProvider.notifier).enqueue(text);
}
Future<void> _drainOfflineQueue() async {
if (!mounted) return;
final queue = ref.read(captureQueueProvider);
if (queue.isEmpty) return;
final api = ref.read(quickCaptureApiProvider);
for (final text in List<String>.from(queue)) {
if (!mounted) break;
try {
final result = await api.capture(text);
if (!mounted) break;
await ref.read(captureQueueProvider.notifier).dequeue(text);
switch (result.type) {
case 'note':
ref.invalidate(notesProvider);
case 'task':
case 'todo':
ref.invalidate(tasksProvider);
ref.invalidate(projectsProvider);
ref.invalidate(projectMilestonesProvider);
}
} on NetworkException {
break;
} catch (_) {
if (mounted) await ref.read(captureQueueProvider.notifier).dequeue(text);
}
}
}
String _hintForLocation(String location) {
if (location.startsWith(Routes.tasks)) return 'Add a task…';
if (location.startsWith(Routes.conversations)) return 'Ask Fabled…';
return 'Capture a note…';
}
@override
Widget build(BuildContext context) {
final location = GoRouterState.of(context).matchedLocation;
final offlineQueueCount = ref.watch(captureQueueProvider).length;
final workQueue = ref.watch(captureWorkQueueProvider);
final isWorking = workQueue.isNotEmpty;
final totalPending = workQueue.length + offlineQueueCount;
// Show snackbar when a result is published.
ref.listen(captureResultProvider, (_, result) {
if (result == null || !mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(result.message),
behavior: SnackBarBehavior.floating,
),
);
});
return SafeArea(
bottom: false,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(12, 8, 4, 4),
child: Row(
children: [
Expanded(
child: TextField(
controller: _controller,
textInputAction: TextInputAction.send,
onSubmitted: (_) => _submit(),
onChanged: (_) => setState(() {}),
decoration: InputDecoration(
hintText: _hintForLocation(location),
isDense: true,
contentPadding: const EdgeInsets.symmetric(
horizontal: 14, vertical: 10),
prefixIcon: totalPending > 0
? Badge(
label: Text('$totalPending'),
child: const Icon(Icons.cloud_upload_outlined),
)
: isWorking
? const Padding(
padding: EdgeInsets.all(12),
child: SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2),
),
)
: const Icon(Icons.auto_awesome_outlined),
suffixIcon: _controller.text.trim().isNotEmpty
? IconButton(
icon: const Icon(Icons.send),
onPressed: _submit,
tooltip: 'Capture',
)
: null,
),
),
),
IconButton(
icon: const Icon(Icons.settings_outlined),
tooltip: 'Settings',
onPressed: () => context.push(Routes.settings),
),
],
),
),
// Thin progress bar while the work queue is draining.
if (isWorking)
const LinearProgressIndicator(minHeight: 2)
else
const SizedBox(height: 2),
],
),
);
}
}
```
- [ ] **Step 3: Remove now-unused `_busy` field and old `_submit`/`_send` methods**
They are fully replaced by the new class above. Verify there are no remaining references to `_busy` or the old `_send` method in `_QuickCaptureBarState`.
- [ ] **Step 4: Analyze**
```bash
flutter analyze lib/app.dart
```
Expected: no errors. Fix any missing imports surfaced by the analyzer.
- [ ] **Step 5: Run and test manually**
```bash
flutter run --debug
```
Test:
1. Type a capture and submit — input clears immediately, progress bar appears briefly, snackbar shows on completion
2. Type and submit 3 captures rapidly — all 3 appear in the queue badge, drain one by one, 3 snackbars appear in sequence
3. The input field is never disabled during processing
- [ ] **Step 6: Commit**
```bash
git add lib/app.dart
git commit -m "feat: multi-item capture work queue with sequential drain and progress indicator"
```
---
## Verification Checklist
- [ ] `flutter analyze` — zero errors
- [ ] App launches with dark slate-indigo background on a device/emulator in dark mode
- [ ] App launches with light theme when device is set to light mode
- [ ] Fraunces font visible in screen titles (e.g. Notes AppBar title)
- [ ] Capture bar: submit while processing → second item queues, badge shows count
- [ ] Capture bar: submit 3 items → all drain sequentially, 3 snackbars
- [ ] Offline: capture → "saved and will retry" snackbar, falls to offline queue
File diff suppressed because it is too large Load Diff