# 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 498–523 (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((_) => null); /// In-memory sequential work queue for quick captures. /// Separate from [captureQueueProvider] (which is the offline persistence queue). final captureWorkQueueProvider = StateNotifierProvider>( (ref) => CaptureWorkQueueNotifier(ref), ); class CaptureWorkQueueNotifier extends StateNotifier> { 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 _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 _drainOfflineQueue() async { if (!mounted) return; final queue = ref.read(captureQueueProvider); if (queue.isEmpty) return; final api = ref.read(quickCaptureApiProvider); for (final text in List.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