6232c7c99a
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>
721 lines
24 KiB
Markdown
721 lines
24 KiB
Markdown
# 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<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
|