diff --git a/docs/superpowers/plans/2026-03-11-app-overhaul-p1-foundation.md b/docs/superpowers/plans/2026-03-11-app-overhaul-p1-foundation.md new file mode 100644 index 0000000..dd4f97d --- /dev/null +++ b/docs/superpowers/plans/2026-03-11-app-overhaul-p1-foundation.md @@ -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 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 diff --git a/docs/superpowers/plans/2026-03-11-app-overhaul-p2-navigation.md b/docs/superpowers/plans/2026-03-11-app-overhaul-p2-navigation.md new file mode 100644 index 0000000..e5430b5 --- /dev/null +++ b/docs/superpowers/plans/2026-03-11-app-overhaul-p2-navigation.md @@ -0,0 +1,1125 @@ +# Fabled App Overhaul — Plan 2: Navigation (Shell + Library + Cleanup) + +> **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. + +**Prerequisite:** Plan 1 (theme + capture queue) must be complete. + +**Goal:** Replace the 4-tab shell (Notes · Tasks · Projects · Chat) with a 3-tab shell (Briefing · Library · Chat), build the unified LibraryScreen with filter pills and inline search, and delete the five screens made redundant by this change. + +**Architecture:** `app.dart`'s `_Shell` is rewritten for 3 tabs. A new `LibraryScreen` replaces the three separate list screens with a unified `GET /api/notes` + `GET /api/tasks` feed filtered by pill selection. A `LibraryItemCard` widget renders note, task, and project rows uniformly. The Briefing tab shows a placeholder screen until Plan 3 ships. Dead screens are deleted. + +**Tech Stack:** Flutter/Dart, Riverpod, GoRouter, Dio (existing), `GradientButton` from Plan 1 theme. + +--- + +## File Map + +**Create:** +- `lib/screens/briefing/briefing_screen.dart` — placeholder (full implementation in Plan 3) +- `lib/screens/library/library_screen.dart` — unified list with filter pills + search +- `lib/widgets/library_item_card.dart` — note / task / project row widget + +**Modify:** +- `lib/app.dart` — rewrite `_Shell` for 3 tabs; add `/briefing` and `/library` routes; remove dead routes +- `lib/core/constants.dart` — add `briefing` and `library` route constants +- `lib/providers/api_client_provider.dart` — no changes needed (existing notes/tasks providers reused) + +**Delete:** +- `lib/screens/notes/notes_list_screen.dart` +- `lib/screens/tasks/tasks_list_screen.dart` +- `lib/screens/projects/project_list_screen.dart` +- `lib/screens/chat/conversations_list_screen.dart` +- `lib/screens/quick_capture/quick_capture_screen.dart` + +--- + +## Chunk 1: Constants + Placeholder Briefing Screen + +### Task 1: Add route constants + +**Files:** +- Modify: `lib/core/constants.dart` + +- [ ] **Step 1: Read `lib/core/constants.dart`** + +Check the existing `Routes` class. It will have constants like `notes`, `tasks`, etc. + +- [ ] **Step 2: Add new constants** + +Add to the `Routes` class: +```dart +static const briefing = '/briefing'; +static const library = '/library'; +``` + +- [ ] **Step 3: Commit** + +```bash +git add lib/core/constants.dart +git commit -m "feat: add briefing and library route constants" +``` + +--- + +### Task 2: Briefing placeholder screen + +**Files:** +- Create: `lib/screens/briefing/briefing_screen.dart` + +This is a placeholder — Plan 3 will replace it entirely. It just renders a centred message so the tab is navigable. + +- [ ] **Step 1: Create `lib/screens/briefing/briefing_screen.dart`** + +```dart +import 'package:flutter/material.dart'; + +/// Placeholder — full implementation in Plan 3. +class BriefingScreen extends StatelessWidget { + const BriefingScreen({super.key}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Scaffold( + appBar: AppBar( + title: Text('Briefing', style: theme.textTheme.titleLarge), + ), + body: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.wb_sunny_outlined, + size: 48, color: theme.colorScheme.primary), + const SizedBox(height: 16), + Text('Briefing coming soon', + style: theme.textTheme.titleMedium), + const SizedBox(height: 8), + Text('Your daily briefing will appear here.', + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant)), + ], + ), + ), + ); + } +} +``` + +- [ ] **Step 2: Analyze** + +```bash +flutter analyze lib/screens/briefing/briefing_screen.dart +``` + +Expected: no errors. + +- [ ] **Step 3: Commit** + +```bash +git add lib/screens/briefing/briefing_screen.dart +git commit -m "feat: briefing placeholder screen" +``` + +--- + +## Chunk 2: Library Screen + +### Task 3: LibraryItemCard widget + +**Files:** +- Create: `lib/widgets/library_item_card.dart` + +A unified card that renders a note row, a task row, or a project row depending on item type. Tapping navigates to the appropriate detail screen. + +- [ ] **Step 1: Read the existing data models** + +Check `lib/data/models/note.dart`, `lib/data/models/task.dart`, `lib/data/models/project.dart` to confirm field names before writing the widget. + +- [ ] **Step 2: Create `lib/widgets/library_item_card.dart`** + +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:intl/intl.dart'; + +import '../core/constants.dart'; +import '../data/models/note.dart'; +import '../data/models/project.dart'; +import '../data/models/task.dart'; +import '../providers/tasks_provider.dart'; + +// ── Note card ──────────────────────────────────────────────────────────────── + +class NoteLibraryCard extends StatelessWidget { + final Note note; + const NoteLibraryCard({super.key, required this.note}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Card( + margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + child: InkWell( + onTap: () => context.push( + Routes.noteDetail.replaceFirst(':id', '${note.id}')), + borderRadius: BorderRadius.circular(14), + child: Padding( + padding: const EdgeInsets.fromLTRB(14, 12, 14, 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.article_outlined, + size: 15, + color: theme.colorScheme.onSurfaceVariant), + const SizedBox(width: 6), + Expanded( + child: Text( + note.title.isNotEmpty ? note.title : 'Untitled', + style: theme.textTheme.titleSmall, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + Text( + _relativeTime(note.updatedAt), + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant), + ), + ], + ), + if (note.body.isNotEmpty) ...[ + const SizedBox(height: 4), + Text( + note.body.replaceAll('\n', ' '), + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + if (note.tags.isNotEmpty) ...[ + const SizedBox(height: 6), + Wrap( + spacing: 4, + runSpacing: 2, + children: note.tags + .take(4) + .map((t) => Chip( + label: Text(t), + materialTapTargetSize: + MaterialTapTargetSize.shrinkWrap, + padding: EdgeInsets.zero, + visualDensity: VisualDensity.compact, + )) + .toList(), + ), + ], + ], + ), + ), + ), + ); + } +} + +// ── Task card ──────────────────────────────────────────────────────────────── + +class TaskLibraryCard extends ConsumerWidget { + final Task task; + const TaskLibraryCard({super.key, required this.task}); + + Color _priorityColor(BuildContext context, String? priority) { + final cs = Theme.of(context).colorScheme; + return switch (priority) { + 'high' => const Color(0xFFEF4444), + 'medium' => const Color(0xFFF59E0B), + _ => cs.onSurfaceVariant, + }; + } + + IconData _statusIcon(String status) => switch (status) { + 'done' => Icons.check_circle, + 'in_progress' => Icons.timelapse, + _ => Icons.radio_button_unchecked, + }; + + Color _statusColor(BuildContext context, String status) { + final cs = Theme.of(context).colorScheme; + return switch (status) { + 'done' => const Color(0xFF22C55E), + 'in_progress' => cs.primary, + _ => cs.onSurfaceVariant, + }; + } + + String _nextStatus(String current) => switch (current) { + 'todo' => 'in_progress', + 'in_progress' => 'done', + _ => 'todo', + }; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + return Card( + margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + child: InkWell( + onTap: () => context.push( + Routes.taskEdit.replaceFirst(':id', '${task.id}')), + borderRadius: BorderRadius.circular(14), + child: Padding( + padding: const EdgeInsets.fromLTRB(8, 10, 14, 10), + child: Row( + children: [ + // Status cycle button + IconButton( + icon: Icon( + _statusIcon(task.status), + color: _statusColor(context, task.status), + ), + onPressed: () => ref + .read(tasksProvider.notifier) + .updateStatus(task.id, _nextStatus(task.status)), + tooltip: 'Cycle status', + visualDensity: VisualDensity.compact, + ), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + task.title.isNotEmpty ? task.title : 'Untitled', + style: theme.textTheme.titleSmall?.copyWith( + decoration: task.status == 'done' + ? TextDecoration.lineThrough + : null, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (task.dueDate != null) ...[ + const SizedBox(height: 2), + Text( + 'Due ${DateFormat.MMMd().format(task.dueDate!)}', + style: theme.textTheme.labelSmall?.copyWith( + color: task.dueDate!.isBefore(DateTime.now()) && + task.status != 'done' + ? const Color(0xFFEF4444) + : theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ], + ), + ), + if (task.priority != null && task.priority != 'none') + Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: _priorityColor(context, task.priority), + shape: BoxShape.circle, + ), + ), + ], + ), + ), + ), + ); + } +} + +// ── Project card ───────────────────────────────────────────────────────────── + +class ProjectLibraryCard extends StatelessWidget { + final Project project; + const ProjectLibraryCard({super.key, required this.project}); + + Color _parseColor(String? hex) { + if (hex == null || hex.isEmpty) return const Color(0xFF6366F1); + try { + return Color(int.parse(hex.replaceFirst('#', '0xFF'))); + } catch (_) { + return const Color(0xFF6366F1); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final color = _parseColor(project.color); + return Card( + margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: () {}, // Projects have no detail screen in this app + borderRadius: BorderRadius.circular(14), + child: Row( + children: [ + // Colour strip + Container(width: 6, height: 64, color: color), + const SizedBox(width: 12), + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + project.title, + style: theme.textTheme.titleSmall, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (project.description?.isNotEmpty == true) ...[ + const SizedBox(height: 2), + Text( + project.description!, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ], + ), + ), + ), + const SizedBox(width: 12), + ], + ), + ), + ); + } +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +String _relativeTime(DateTime? dt) { + if (dt == null) return ''; + final diff = DateTime.now().difference(dt); + if (diff.inMinutes < 1) return 'just now'; + if (diff.inHours < 1) return '${diff.inMinutes}m ago'; + if (diff.inDays < 1) return '${diff.inHours}h ago'; + if (diff.inDays < 7) return '${diff.inDays}d ago'; + return DateFormat.MMMd().format(dt); +} +``` + +Note: `Routes.noteDetail` and `Routes.taskEdit` use path patterns like `/notes/:id` — check `lib/core/constants.dart` for exact values and adjust `replaceFirst` calls accordingly. + +Also note: `tasksProvider.notifier.updateStatus()` — check `lib/providers/tasks_provider.dart` to verify this method exists. If not, add it (it calls `PATCH /api/tasks/:id` with `{status}`). + +Also: `intl` package may need adding to `pubspec.yaml` — check if it's already a transitive dependency via `flutter pub deps`. If not, add `intl: ^0.19.0`. + +- [ ] **Step 3: Analyze** + +```bash +flutter analyze lib/widgets/library_item_card.dart +``` + +Fix any type errors (common: nullable field access, missing `fromJson` fields on models). + +- [ ] **Step 4: Commit** + +```bash +git add lib/widgets/library_item_card.dart +git commit -m "feat: LibraryItemCard — note, task, and project row widgets" +``` + +--- + +### Task 4: LibraryScreen + +**Files:** +- Create: `lib/screens/library/library_screen.dart` + +- [ ] **Step 1: Create `lib/screens/library/library_screen.dart`** + +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../core/constants.dart'; +import '../../data/models/note.dart'; +import '../../data/models/task.dart'; +import '../../providers/notes_provider.dart'; +import '../../providers/projects_provider.dart'; +import '../../providers/tasks_provider.dart'; +import '../../widgets/library_item_card.dart'; + +enum _LibraryFilter { all, notes, tasks, projects } +enum _TaskStatusFilter { all, todo, inProgress, done } + +class LibraryScreen extends ConsumerStatefulWidget { + const LibraryScreen({super.key}); + + @override + ConsumerState createState() => _LibraryScreenState(); +} + +class _LibraryScreenState extends ConsumerState { + _LibraryFilter _filter = _LibraryFilter.all; + _TaskStatusFilter _taskStatus = _TaskStatusFilter.all; + bool _searchActive = false; + String _searchQuery = ''; + final _searchController = TextEditingController(); + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + bool _matchesSearch(String text) { + if (_searchQuery.isEmpty) return true; + return text.toLowerCase().contains(_searchQuery.toLowerCase()); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final notesAsync = ref.watch(notesProvider); + final tasksAsync = ref.watch(tasksProvider); + final projectsAsync = ref.watch(projectsProvider); + + return Scaffold( + appBar: AppBar( + title: _searchActive + ? TextField( + controller: _searchController, + autofocus: true, + decoration: const InputDecoration( + hintText: 'Search…', + border: InputBorder.none, + isDense: true, + ), + onChanged: (q) => setState(() => _searchQuery = q), + ) + : Text('Library', style: theme.textTheme.titleLarge), + actions: [ + IconButton( + icon: Icon(_searchActive ? Icons.close : Icons.search), + onPressed: () => setState(() { + _searchActive = !_searchActive; + if (!_searchActive) { + _searchQuery = ''; + _searchController.clear(); + } + }), + ), + ], + ), + body: Column( + children: [ + // ── Filter pills ────────────────────────────────────────────────── + SingleChildScrollView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.fromLTRB(12, 8, 12, 4), + child: Row( + children: _LibraryFilter.values.map((f) { + final label = switch (f) { + _LibraryFilter.all => 'All', + _LibraryFilter.notes => 'Notes', + _LibraryFilter.tasks => 'Tasks', + _LibraryFilter.projects => 'Projects', + }; + final selected = _filter == f; + return Padding( + padding: const EdgeInsets.only(right: 8), + child: FilterChip( + label: Text(label), + selected: selected, + onSelected: (_) => setState(() { + _filter = f; + _taskStatus = _TaskStatusFilter.all; + }), + selectedColor: + theme.colorScheme.primary.withValues(alpha: 0.18), + checkmarkColor: theme.colorScheme.primary, + ), + ); + }).toList(), + ), + ), + + // ── Task status sub-filter (Tasks pill only) ─────────────────── + if (_filter == _LibraryFilter.tasks) + SingleChildScrollView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.fromLTRB(12, 0, 12, 4), + child: Row( + children: _TaskStatusFilter.values.map((s) { + final label = switch (s) { + _TaskStatusFilter.all => 'All', + _TaskStatusFilter.todo => 'To Do', + _TaskStatusFilter.inProgress => 'In Progress', + _TaskStatusFilter.done => 'Done', + }; + return Padding( + padding: const EdgeInsets.only(right: 8), + child: ChoiceChip( + label: Text(label), + selected: _taskStatus == s, + onSelected: (_) => setState(() => _taskStatus = s), + selectedColor: + theme.colorScheme.secondary.withValues(alpha: 0.15), + ), + ); + }).toList(), + ), + ), + + const Divider(height: 1), + + // ── Content ─────────────────────────────────────────────────────── + Expanded( + child: switch (_filter) { + _LibraryFilter.notes => _buildNotesList(notesAsync), + _LibraryFilter.tasks => _buildTasksList(tasksAsync), + _LibraryFilter.projects => _buildProjectsList(projectsAsync), + _LibraryFilter.all => _buildAllList(notesAsync, tasksAsync), + }, + ), + ], + ), + floatingActionButton: FloatingActionButton( + onPressed: () => _showCreateSheet(context), + child: const Icon(Icons.add), + ), + ); + } + + Widget _buildNotesList(AsyncValue> notesAsync) { + return notesAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => Center(child: Text('Error: $e')), + data: (notes) { + final filtered = notes + .where((n) => _matchesSearch(n.title) || _matchesSearch(n.body)) + .toList(); + if (filtered.isEmpty) { + return const Center(child: Text('No notes found')); + } + return RefreshIndicator( + onRefresh: () async => ref.invalidate(notesProvider), + child: ListView.builder( + itemCount: filtered.length, + itemBuilder: (_, i) => NoteLibraryCard(note: filtered[i]), + ), + ); + }, + ); + } + + Widget _buildTasksList(AsyncValue> tasksAsync) { + return tasksAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => Center(child: Text('Error: $e')), + data: (tasks) { + var filtered = tasks + .where((t) => _matchesSearch(t.title)) + .toList(); + if (_taskStatus != _TaskStatusFilter.all) { + final status = switch (_taskStatus) { + _TaskStatusFilter.todo => 'todo', + _TaskStatusFilter.inProgress => 'in_progress', + _TaskStatusFilter.done => 'done', + _ => '', + }; + filtered = filtered.where((t) => t.status == status).toList(); + } + if (filtered.isEmpty) { + return const Center(child: Text('No tasks found')); + } + return RefreshIndicator( + onRefresh: () async => ref.invalidate(tasksProvider), + child: ListView.builder( + itemCount: filtered.length, + itemBuilder: (_, i) => TaskLibraryCard(task: filtered[i]), + ), + ); + }, + ); + } + + Widget _buildProjectsList(AsyncValue projects) { + return projects.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => Center(child: Text('Error: $e')), + data: (data) { + final list = data as List; + if (list.isEmpty) { + return const Center(child: Text('No projects')); + } + return RefreshIndicator( + onRefresh: () async => ref.invalidate(projectsProvider), + child: ListView.builder( + itemCount: list.length, + itemBuilder: (_, i) => ProjectLibraryCard(project: list[i]), + ), + ); + }, + ); + } + + Widget _buildAllList( + AsyncValue> notesAsync, + AsyncValue> tasksAsync, + ) { + final notes = notesAsync.valueOrNull ?? []; + final tasks = tasksAsync.valueOrNull ?? []; + + // Merge and sort by updatedAt desc + final items = <(DateTime, Widget)>[]; + for (final n in notes) { + if (_matchesSearch(n.title) || _matchesSearch(n.body)) { + items.add((n.updatedAt ?? DateTime(0), NoteLibraryCard(note: n))); + } + } + for (final t in tasks) { + if (_matchesSearch(t.title)) { + items.add((t.updatedAt ?? DateTime(0), TaskLibraryCard(task: t))); + } + } + items.sort((a, b) => b.$1.compareTo(a.$1)); + + if (notesAsync.isLoading || tasksAsync.isLoading) { + return const Center(child: CircularProgressIndicator()); + } + if (items.isEmpty) { + return const Center(child: Text('Nothing here yet')); + } + return RefreshIndicator( + onRefresh: () async { + ref.invalidate(notesProvider); + ref.invalidate(tasksProvider); + }, + child: ListView.builder( + itemCount: items.length, + itemBuilder: (_, i) => items[i].$2, + ), + ); + } + + void _showCreateSheet(BuildContext context) { + showModalBottomSheet( + context: context, + builder: (_) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: const Icon(Icons.article_outlined), + title: const Text('New note'), + onTap: () { + Navigator.pop(context); + context.push(Routes.noteNew); + }, + ), + ListTile( + leading: const Icon(Icons.check_box_outlined), + title: const Text('New task'), + onTap: () { + Navigator.pop(context); + context.push(Routes.taskNew); + }, + ), + ], + ), + ), + ); + } +} +``` + +- [ ] **Step 2: Check model field names** + +Read `lib/data/models/note.dart` and `lib/data/models/task.dart`. Verify: +- `Note` has: `id`, `title`, `body`, `tags` (List), `updatedAt` (DateTime?) +- `Task` has: `id`, `title`, `status`, `priority`, `dueDate` (DateTime?), `updatedAt` (DateTime?) +- `Project` has: `id`, `title`, `description`, `color` + +Adjust field names in `LibraryScreen` and `LibraryItemCard` if they differ. + +- [ ] **Step 3: Verify `tasksProvider.notifier` has `updateStatus()`** + +Check `lib/providers/tasks_provider.dart`. If `updateStatus(int id, String status)` doesn't exist, add it: +```dart +Future updateStatus(int id, String status) async { + await ref.read(tasksRepositoryProvider).updateStatus(id, status); + ref.invalidateSelf(); +} +``` + +And add to `lib/data/repositories/tasks_repository.dart`: +```dart +Future updateStatus(int id, String status) async { + await _api.updateStatus(id, status); +} +``` + +And add to `lib/data/api/tasks_api.dart`: +```dart +Future updateStatus(int id, String status) async { + try { + await _dio.patch('/api/tasks/$id/status', data: {'status': status}); + } on DioException catch (e) { + throw dioToApp(e); + } +} +``` + +- [ ] **Step 4: Analyze** + +```bash +flutter analyze lib/screens/library/ lib/widgets/library_item_card.dart +``` + +Fix any errors. + +- [ ] **Step 5: Commit** + +```bash +git add lib/screens/library/ lib/widgets/library_item_card.dart +git commit -m "feat: LibraryScreen — unified notes/tasks/projects with filter pills and search" +``` + +--- + +## Chunk 3: Shell Rewrite + Dead Code Cleanup + +### Task 5: Rewrite the shell and routes + +**Files:** +- Modify: `lib/app.dart` — `_Shell` class, `routerProvider` routes list + +- [ ] **Step 1: Update imports in `lib/app.dart`** + +Add: +```dart +import 'screens/briefing/briefing_screen.dart'; +import 'screens/library/library_screen.dart'; +``` + +Remove imports for deleted screens (they will be deleted next): +```dart +// Remove these: +import 'screens/notes/notes_list_screen.dart'; +import 'screens/tasks/tasks_list_screen.dart'; +import 'screens/projects/project_list_screen.dart'; +import 'screens/chat/conversations_list_screen.dart'; +``` + +- [ ] **Step 2: Update the shell tab list** + +Find `static const _tabs = [Routes.notes, Routes.tasks, Routes.projects, Routes.conversations];` + +Replace with: +```dart +static const _tabs = [Routes.briefing, Routes.library, Routes.conversations]; +``` + +- [ ] **Step 3: Update NavigationBar destinations** + +Replace the 4-destination `NavigationBar` with: +```dart +bottomNavigationBar: NavigationBar( + selectedIndex: index, + onDestinationSelected: (i) => context.go(_tabs[i]), + destinations: const [ + NavigationDestination( + icon: Icon(Icons.wb_sunny_outlined), + selectedIcon: Icon(Icons.wb_sunny), + label: 'Briefing', + ), + NavigationDestination( + icon: Icon(Icons.library_books_outlined), + selectedIcon: Icon(Icons.library_books), + label: 'Library', + ), + NavigationDestination( + icon: Icon(Icons.chat_bubble_outline), + selectedIcon: Icon(Icons.chat_bubble), + label: 'Chat', + ), + ], +), +``` + +- [ ] **Step 4: Update NavigationRail destinations (wide layout)** + +Replace the 4-destination `NavigationRail` with: +```dart +NavigationRail( + selectedIndex: index, + onDestinationSelected: (i) => context.go(_tabs[i]), + labelType: NavigationRailLabelType.all, + destinations: const [ + NavigationRailDestination( + icon: Icon(Icons.wb_sunny_outlined), + selectedIcon: Icon(Icons.wb_sunny), + label: Text('Briefing'), + ), + NavigationRailDestination( + icon: Icon(Icons.library_books_outlined), + selectedIcon: Icon(Icons.library_books), + label: Text('Library'), + ), + NavigationRailDestination( + icon: Icon(Icons.chat_bubble_outline), + selectedIcon: Icon(Icons.chat_bubble), + label: Text('Chat'), + ), + ], +), +``` + +- [ ] **Step 5: Update the ShellRoute in routerProvider** + +Find the `ShellRoute` with its child `GoRoute`s. Replace: +```dart +ShellRoute( + builder: (context, state, child) => _Shell(child: child), + routes: [ + GoRoute(path: Routes.briefing, builder: (_, _) => const BriefingScreen()), + GoRoute(path: Routes.library, builder: (_, _) => const LibraryScreen()), + GoRoute(path: Routes.conversations, builder: (_, _) => const ConversationsInlineScreen()), + ], +), +``` + +For the Chat tab: the existing `ConversationsListScreen` is being deleted. Replace it with an inline conversations list built directly in the ShellRoute, or create a minimal `lib/screens/chat/conversations_tab_screen.dart` that replaces it. The simplest approach: move the conversations list content inline. + +Create `lib/screens/chat/conversations_tab_screen.dart`: +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../core/constants.dart'; +import '../../providers/chat_provider.dart'; + +class ConversationsTabScreen extends ConsumerWidget { + const ConversationsTabScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + final convsAsync = ref.watch(conversationsProvider); + + return Scaffold( + appBar: AppBar( + title: Text('Chat', style: theme.textTheme.titleLarge), + actions: [ + IconButton( + icon: const Icon(Icons.add), + tooltip: 'New conversation', + onPressed: () async { + final conv = await ref + .read(conversationsProvider.notifier) + .create('New conversation'); + if (context.mounted) { + context.push(Routes.chat.replaceFirst(':id', '${conv.id}')); + } + }, + ), + ], + ), + body: convsAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => Center(child: Text('Error: $e')), + data: (convs) { + if (convs.isEmpty) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.chat_bubble_outline, + size: 48, + color: theme.colorScheme.onSurfaceVariant), + const SizedBox(height: 16), + Text('No conversations yet', + style: theme.textTheme.titleMedium), + const SizedBox(height: 8), + FilledButton.icon( + icon: const Icon(Icons.add), + label: const Text('Start a conversation'), + onPressed: () async { + final conv = await ref + .read(conversationsProvider.notifier) + .create('New conversation'); + if (context.mounted) { + context.push( + Routes.chat.replaceFirst(':id', '${conv.id}')); + } + }, + ), + ], + ), + ); + } + return RefreshIndicator( + onRefresh: () async => ref.invalidate(conversationsProvider), + child: ListView.builder( + itemCount: convs.length, + itemBuilder: (ctx, i) { + final c = convs[i]; + return ListTile( + leading: const Icon(Icons.chat_bubble_outline), + title: Text(c.title, maxLines: 1, overflow: TextOverflow.ellipsis), + subtitle: c.updatedAt != null + ? Text( + _relativeTime(c.updatedAt!), + style: theme.textTheme.labelSmall, + ) + : null, + trailing: IconButton( + icon: const Icon(Icons.delete_outline), + onPressed: () => _confirmDelete(context, ref, c.id, c.title), + ), + onTap: () => ctx.push( + Routes.chat.replaceFirst(':id', '${c.id}')), + ); + }, + ), + ); + }, + ), + ); + } + + Future _confirmDelete( + BuildContext context, WidgetRef ref, int id, String title) async { + final ok = await showDialog( + context: context, + builder: (_) => AlertDialog( + title: const Text('Delete conversation?'), + content: Text('"$title" will be permanently deleted.'), + actions: [ + TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Cancel')), + FilledButton(onPressed: () => Navigator.pop(context, true), child: const Text('Delete')), + ], + ), + ); + if (ok == true) { + await ref.read(conversationsProvider.notifier).delete(id); + } + } +} + +String _relativeTime(DateTime dt) { + final diff = DateTime.now().difference(dt); + if (diff.inMinutes < 1) return 'just now'; + if (diff.inHours < 1) return '${diff.inMinutes}m ago'; + if (diff.inDays < 1) return '${diff.inHours}h ago'; + if (diff.inDays < 7) return '${diff.inDays}d ago'; + return '${dt.day}/${dt.month}/${dt.year}'; +} +``` + +Then in the ShellRoute, use `ConversationsTabScreen`: +```dart +import 'screens/chat/conversations_tab_screen.dart'; +// ... +GoRoute(path: Routes.conversations, builder: (_, _) => const ConversationsTabScreen()), +``` + +- [ ] **Step 6: Update initial location** + +In `GoRouter(initialLocation: ...)`, change: +```dart +initialLocation: Routes.splash, +``` +(This stays — the splash screen handles auth redirect. After auth, redirect to briefing. Update the redirect logic to send authenticated users to `Routes.briefing` instead of falling through to `Routes.notes`.) + +In the `redirect` callback, ensure unauthenticated users still go to login, and authenticated users landing on `/` or `/notes`/`/tasks`/`/projects` are redirected to `/briefing`. The simplest: no change needed — GoRouter will use the shell's first tab (`Routes.briefing`) naturally as the initial shell route. + +- [ ] **Step 7: Analyze** + +```bash +flutter analyze lib/app.dart lib/screens/ +``` + +Fix any errors. + +- [ ] **Step 8: Commit** + +```bash +git add lib/app.dart lib/screens/chat/conversations_tab_screen.dart lib/core/constants.dart +git commit -m "feat: 3-tab shell — Briefing, Library, Chat" +``` + +--- + +### Task 6: Delete dead screens + +**Files:** +- Delete: 5 screen files + +- [ ] **Step 1: Delete the dead files** + +```bash +cd /home/bvandeusen/Nextcloud/Projects/fabled_app +git rm lib/screens/notes/notes_list_screen.dart +git rm lib/screens/tasks/tasks_list_screen.dart +git rm lib/screens/projects/project_list_screen.dart +git rm lib/screens/chat/conversations_list_screen.dart +git rm lib/screens/quick_capture/quick_capture_screen.dart +``` + +- [ ] **Step 2: Verify no remaining imports** + +```bash +grep -r "notes_list_screen\|tasks_list_screen\|project_list_screen\|conversations_list_screen\|quick_capture_screen" lib/ +``` + +Expected: no output. + +- [ ] **Step 3: Analyze full project** + +```bash +flutter analyze +``` + +Expected: zero errors. + +- [ ] **Step 4: Run and verify** + +```bash +flutter run --debug +``` + +Verify: +- App opens to Briefing tab (placeholder screen) +- Library tab shows unified list with filter pills +- Tasks filter → status sub-filter appears +- Tapping a note navigates to `NoteDetailScreen` +- Chat tab shows conversations list +- FAB on Library → bottom sheet with "New note" / "New task" + +- [ ] **Step 5: Commit** + +```bash +git commit -m "chore: delete dead list screens replaced by LibraryScreen" +``` + +--- + +## Verification Checklist + +- [ ] `flutter analyze` — zero errors +- [ ] App opens to Briefing tab +- [ ] Library → All: notes and tasks interleaved, sorted by date +- [ ] Library → Notes: only notes +- [ ] Library → Tasks: tasks + status sub-filter works +- [ ] Library → Projects: project cards with colour strip +- [ ] Library search: typing filters results live +- [ ] Task status cycle: tapping checkbox icon on a task card cycles status +- [ ] FAB → bottom sheet → "New note" / "New task" navigates correctly +- [ ] Chat tab: conversations list, new conversation creates and navigates to ChatScreen +- [ ] No references to deleted files remain diff --git a/lib/app.dart b/lib/app.dart index 27116d9..db52833 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -4,27 +4,26 @@ import 'package:go_router/go_router.dart'; import 'core/constants.dart'; import 'core/exceptions.dart'; +import 'core/theme.dart'; import 'providers/api_client_provider.dart'; import 'providers/auth_provider.dart'; import 'providers/capture_queue_provider.dart'; -import 'providers/milestones_provider.dart'; +import 'providers/capture_work_queue_provider.dart'; import 'providers/notes_provider.dart'; -import 'providers/projects_provider.dart'; import 'providers/settings_provider.dart'; import 'providers/update_provider.dart'; import 'providers/tasks_provider.dart'; import 'screens/auth/login_screen.dart'; +import 'screens/briefing/briefing_screen.dart'; import 'screens/chat/chat_screen.dart'; -import 'screens/chat/conversations_list_screen.dart'; +import 'screens/chat/conversations_tab_screen.dart'; +import 'screens/library/library_screen.dart'; import 'screens/notes/note_detail_screen.dart'; import 'screens/notes/note_edit_screen.dart'; -import 'screens/notes/notes_list_screen.dart'; -import 'screens/projects/project_list_screen.dart'; import 'screens/settings/settings_screen.dart'; import 'screens/setup/setup_screen.dart'; import 'screens/splash/splash_screen.dart'; import 'screens/tasks/task_edit_screen.dart'; -import 'screens/tasks/tasks_list_screen.dart'; // ChangeNotifier that fires when auth or server URL changes, // used as GoRouter.refreshListenable so the router re-evaluates redirects @@ -118,20 +117,16 @@ final routerProvider = Provider((ref) { builder: (context, state, child) => _Shell(child: child), routes: [ GoRoute( - path: Routes.notes, - builder: (_, _) => const NotesListScreen(), + path: Routes.briefing, + builder: (_, _) => const BriefingScreen(), ), GoRoute( - path: Routes.tasks, - builder: (_, _) => const TasksListScreen(), - ), - GoRoute( - path: Routes.projects, - builder: (_, _) => const ProjectListScreen(), + path: Routes.library, + builder: (_, _) => const LibraryScreen(), ), GoRoute( path: Routes.conversations, - builder: (_, _) => const ConversationsListScreen(), + builder: (_, _) => const ConversationsTabScreen(), ), ], ), @@ -148,7 +143,11 @@ class _Shell extends ConsumerStatefulWidget { } class _ShellState extends ConsumerState<_Shell> { - static const _tabs = [Routes.notes, Routes.tasks, Routes.projects, Routes.conversations]; + static const _tabs = [ + Routes.briefing, + Routes.library, + Routes.conversations, + ]; @override void initState() { @@ -236,8 +235,6 @@ class _ShellState extends ConsumerState<_Shell> { final location = GoRouterState.of(context).matchedLocation; final index = _tabIndex(location); final child = widget.child; - // Use NavigationRail whenever the screen is wide enough — covers both - // phone landscape and tablets in either orientation (600 dp breakpoint). final isWide = MediaQuery.of(context).size.width >= 600; if (isWide) { @@ -255,19 +252,14 @@ class _ShellState extends ConsumerState<_Shell> { labelType: NavigationRailLabelType.all, destinations: const [ NavigationRailDestination( - icon: Icon(Icons.note_outlined), - selectedIcon: Icon(Icons.note), - label: Text('Notes'), + icon: Icon(Icons.wb_sunny_outlined), + selectedIcon: Icon(Icons.wb_sunny), + label: Text('Briefing'), ), NavigationRailDestination( - icon: Icon(Icons.check_box_outlined), - selectedIcon: Icon(Icons.check_box), - label: Text('Tasks'), - ), - NavigationRailDestination( - icon: Icon(Icons.folder_outlined), - selectedIcon: Icon(Icons.folder), - label: Text('Projects'), + icon: Icon(Icons.library_books_outlined), + selectedIcon: Icon(Icons.library_books), + label: Text('Library'), ), NavigationRailDestination( icon: Icon(Icons.chat_bubble_outline), @@ -298,11 +290,21 @@ class _ShellState extends ConsumerState<_Shell> { selectedIndex: index, onDestinationSelected: (i) => context.go(_tabs[i]), destinations: const [ - NavigationDestination(icon: Icon(Icons.note), label: 'Notes'), - NavigationDestination(icon: Icon(Icons.check_box), label: 'Tasks'), - NavigationDestination(icon: Icon(Icons.folder), label: 'Projects'), NavigationDestination( - icon: Icon(Icons.chat_bubble), label: 'Chat'), + icon: Icon(Icons.wb_sunny_outlined), + selectedIcon: Icon(Icons.wb_sunny), + label: 'Briefing', + ), + NavigationDestination( + icon: Icon(Icons.library_books_outlined), + selectedIcon: Icon(Icons.library_books), + label: 'Library', + ), + NavigationDestination( + icon: Icon(Icons.chat_bubble_outline), + selectedIcon: Icon(Icons.chat_bubble), + label: 'Chat', + ), ], ), ); @@ -318,13 +320,11 @@ class _QuickCaptureBar extends ConsumerStatefulWidget { class _QuickCaptureBarState extends ConsumerState<_QuickCaptureBar> { final _controller = TextEditingController(); - bool _busy = false; @override void initState() { super.initState(); - // Retry any offline-queued captures from previous sessions. - WidgetsBinding.instance.addPostFrameCallback((_) => _drainQueue()); + WidgetsBinding.instance.addPostFrameCallback((_) => _drainOfflineQueue()); } @override @@ -333,63 +333,15 @@ class _QuickCaptureBarState extends ConsumerState<_QuickCaptureBar> { super.dispose(); } - Future _submit() async { + void _submit() { final text = _controller.text.trim(); - if (text.isEmpty || _busy) return; - + if (text.isEmpty) return; _controller.clear(); - setState(() => _busy = true); - - // Capture the messenger before the async gap so we can show a snackbar - // even if the user has navigated to a deeper view by the time it resolves. - final messenger = ScaffoldMessenger.of(context); - - try { - final result = await ref.read(quickCaptureApiProvider).capture(text); - - switch (result.type) { - case 'note': - ref.invalidate(notesProvider); - case 'task': - case 'todo': - ref.invalidate(tasksProvider); - ref.invalidate(projectsProvider); - ref.invalidate(projectMilestonesProvider); - } - - final msg = result.message.isNotEmpty - ? result.message - : '${_typeLabel(result.type)} created: ${result.title}'; - messenger.showSnackBar( - SnackBar(content: Text(msg), behavior: SnackBarBehavior.floating), - ); - _drainQueue(); // Silently flush any offline-queued captures. - } on NetworkException { - await ref.read(captureQueueProvider.notifier).enqueue(text); - messenger.showSnackBar( - const SnackBar( - content: Text( - "You're offline — capture saved and will retry automatically."), - behavior: SnackBarBehavior.floating, - ), - ); - } on AppException catch (e) { - messenger.showSnackBar( - SnackBar(content: Text(e.message), behavior: SnackBarBehavior.floating), - ); - } catch (_) { - messenger.showSnackBar( - const SnackBar( - content: Text('Capture failed. Please try again.'), - behavior: SnackBarBehavior.floating, - ), - ); - } finally { - if (mounted) setState(() => _busy = false); - } + setState(() {}); // clear suffix icon + ref.read(captureWorkQueueProvider.notifier).enqueue(text); } - Future _drainQueue() async { + Future _drainOfflineQueue() async { if (!mounted) return; final queue = ref.read(captureQueueProvider); if (queue.isEmpty) return; @@ -406,28 +358,20 @@ class _QuickCaptureBarState extends ConsumerState<_QuickCaptureBar> { case 'task': case 'todo': ref.invalidate(tasksProvider); - ref.invalidate(projectsProvider); - ref.invalidate(projectMilestonesProvider); } } on NetworkException { - break; // Still offline — stop draining. + break; } catch (_) { - // Server/parse error — remove to avoid infinite retries. - if (mounted) await ref.read(captureQueueProvider.notifier).dequeue(text); + if (mounted) { + await ref.read(captureQueueProvider.notifier).dequeue(text); + } } } } - String _typeLabel(String type) => switch (type) { - 'note' => 'Note', - 'task' => 'Task', - 'event' => 'Event', - 'todo' => 'To-do', - _ => type, - }; - String _hintForLocation(String location) { - if (location.startsWith(Routes.tasks)) return 'Add a task…'; + if (location.startsWith(Routes.library) && + location.contains('tasks')) return 'Add a task…'; if (location.startsWith(Routes.conversations)) return 'Ask Fabled…'; return 'Capture a note…'; } @@ -435,61 +379,82 @@ class _QuickCaptureBarState extends ConsumerState<_QuickCaptureBar> { @override Widget build(BuildContext context) { final location = GoRouterState.of(context).matchedLocation; - final queueCount = ref.watch(captureQueueProvider).length; + 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: Padding( - padding: const EdgeInsets.fromLTRB(12, 8, 4, 4), - child: Row( - children: [ - Expanded( - child: TextField( - controller: _controller, - enabled: !_busy, - textInputAction: TextInputAction.send, - onSubmitted: (_) => _submit(), - onChanged: (_) => setState(() {}), - decoration: InputDecoration( - hintText: _hintForLocation(location), - isDense: true, - contentPadding: - const EdgeInsets.symmetric(horizontal: 14, vertical: 10), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(24), - ), - prefixIcon: _busy - ? const Padding( - padding: EdgeInsets.all(12), - child: SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator(strokeWidth: 2), - ), - ) - : queueCount > 0 + 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('$queueCount'), - child: - const Icon(Icons.cloud_upload_outlined), + label: Text('$totalPending'), + child: const Icon(Icons.cloud_upload_outlined), ) - : const Icon(Icons.auto_awesome_outlined), - suffixIcon: _controller.text.trim().isNotEmpty && !_busy - ? IconButton( - icon: const Icon(Icons.send), - onPressed: _submit, - tooltip: 'Capture', - ) - : null, + : 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), + ), + ], ), - 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), + ], ), ); } @@ -506,17 +471,8 @@ class FabledApp extends ConsumerWidget { return MaterialApp.router( title: 'Fabled', themeMode: themeMode, - theme: ThemeData( - colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo), - useMaterial3: true, - ), - darkTheme: ThemeData( - colorScheme: ColorScheme.fromSeed( - seedColor: Colors.indigo, - brightness: Brightness.dark, - ), - useMaterial3: true, - ), + theme: fabledLightTheme(), + darkTheme: fabledDarkTheme(), routerConfig: router, ); } diff --git a/lib/core/constants.dart b/lib/core/constants.dart index 8b7b568..5400dac 100644 --- a/lib/core/constants.dart +++ b/lib/core/constants.dart @@ -14,4 +14,6 @@ abstract class Routes { static const chat = '/chat/:id'; static const quickCapture = '/quick-capture'; static const settings = '/settings'; + static const briefing = '/briefing'; + static const library = '/library'; } diff --git a/lib/core/theme.dart b/lib/core/theme.dart new file mode 100644 index 0000000..c6fb537 --- /dev/null +++ b/lib/core/theme.dart @@ -0,0 +1,246 @@ +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: CardThemeData( + 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: CardThemeData( + 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), + ), + ), + ), + ); + } +} diff --git a/lib/data/api/briefing_api.dart b/lib/data/api/briefing_api.dart new file mode 100644 index 0000000..60929c5 --- /dev/null +++ b/lib/data/api/briefing_api.dart @@ -0,0 +1,62 @@ +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 getToday() async { + try { + final response = await _dio.get('/api/briefing/conversations/today'); + return BriefingConversation.fromJson( + response.data as Map); + } on DioException catch (e) { + throw dioToApp(e); + } + } + + /// GET /api/briefing/conversations + /// Returns list of past briefing conversations (no messages embedded). + Future> getHistory() async { + try { + final response = await _dio.get('/api/briefing/conversations'); + final data = response.data as Map; + final list = data['conversations'] as List; + return list + .map((e) => BriefingConversation.fromJson(e as Map)) + .toList(); + } on DioException catch (e) { + throw dioToApp(e); + } + } + + /// GET /api/briefing/conversations//messages + Future> getMessages(int convId) async { + try { + final response = + await _dio.get('/api/briefing/conversations/$convId/messages'); + final data = response.data as Map; + final list = data['messages'] as List; + return list + .map((e) => Message.fromJson(e as Map)) + .toList(); + } on DioException catch (e) { + throw dioToApp(e); + } + } + + /// POST /api/briefing/trigger body: {"slot": slot} + /// slot: "compilation" | "morning" | "midday" | "afternoon" + Future triggerSlot(String slot) async { + try { + await _dio.post('/api/briefing/trigger', data: {'slot': slot}); + } on DioException catch (e) { + throw dioToApp(e); + } + } +} diff --git a/lib/data/models/briefing_conversation.dart b/lib/data/models/briefing_conversation.dart new file mode 100644 index 0000000..31b6dd0 --- /dev/null +++ b/lib/data/models/briefing_conversation.dart @@ -0,0 +1,35 @@ +import 'message.dart'; + +class BriefingConversation { + final int id; + final String title; + final String? briefingDate; // YYYY-MM-DD or null + final List messages; + + const BriefingConversation({ + required this.id, + required this.title, + this.briefingDate, + required this.messages, + }); + + factory BriefingConversation.fromJson(Map json) { + final rawMessages = json['messages'] as List? ?? []; + 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)) + .toList(), + ); + } + + BriefingConversation copyWith({List? messages}) => + BriefingConversation( + id: id, + title: title, + briefingDate: briefingDate, + messages: messages ?? this.messages, + ); +} diff --git a/lib/providers/api_client_provider.dart b/lib/providers/api_client_provider.dart index fe95f61..de7be00 100644 --- a/lib/providers/api_client_provider.dart +++ b/lib/providers/api_client_provider.dart @@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../data/api/api_client.dart'; import '../data/api/auth_api.dart'; +import '../data/api/briefing_api.dart'; import '../data/api/chat_api.dart'; import '../data/api/milestones_api.dart'; import '../data/api/notes_api.dart'; @@ -80,3 +81,7 @@ final milestonesApiProvider = Provider((ref) { final milestonesRepositoryProvider = Provider((ref) { return MilestonesRepository(ref.watch(milestonesApiProvider)); }); + +final briefingApiProvider = Provider((ref) { + return BriefingApi(ref.watch(dioProvider)); +}); diff --git a/lib/providers/briefing_provider.dart b/lib/providers/briefing_provider.dart new file mode 100644 index 0000000..3597552 --- /dev/null +++ b/lib/providers/briefing_provider.dart @@ -0,0 +1,113 @@ +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 reply area. +final isBriefingStreamingProvider = StateProvider((ref) => false); + +final briefingProvider = + AsyncNotifierProvider( + BriefingNotifier.new); + +class BriefingNotifier extends AsyncNotifier { + @override + Future build() async { + return ref.read(briefingApiProvider).getToday(); + } + + /// Trigger a briefing slot (e.g. "compilation") then reload. + Future refresh(String slot) async { + await ref.read(briefingApiProvider).triggerSlot(slot); + ref.invalidateSelf(); + await future; + } + + /// 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 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; + } + } +} diff --git a/lib/providers/capture_work_queue_provider.dart b/lib/providers/capture_work_queue_provider.dart new file mode 100644 index 0000000..3c83ed8 --- /dev/null +++ b/lib/providers/capture_work_queue_provider.dart @@ -0,0 +1,98 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../core/exceptions.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.", + ); + 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, + }; +} diff --git a/lib/screens/briefing/briefing_history_screen.dart b/lib/screens/briefing/briefing_history_screen.dart new file mode 100644 index 0000000..f40d66b --- /dev/null +++ b/lib/screens/briefing/briefing_history_screen.dart @@ -0,0 +1,88 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.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>((ref) async { + return ref.watch(briefingApiProvider).getHistory(); +}); + +final _briefingMessagesProvider = + FutureProvider.family, int>((ref, convId) async { + return ref.watch(briefingApiProvider).getMessages(convId); +}); diff --git a/lib/screens/briefing/briefing_screen.dart b/lib/screens/briefing/briefing_screen.dart new file mode 100644 index 0000000..69a71ad --- /dev/null +++ b/lib/screens/briefing/briefing_screen.dart @@ -0,0 +1,299 @@ +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_history_screen.dart'; + +class BriefingScreen extends ConsumerStatefulWidget { + const BriefingScreen({super.key}); + + @override + ConsumerState createState() => _BriefingScreenState(); +} + +class _BriefingScreenState extends ConsumerState { + 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 _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 _refresh() async { + setState(() => _refreshing = true); + try { + await ref.read(briefingProvider.notifier).refresh('compilation'); + } catch (_) { + 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: [ + Text('Briefing', style: Theme.of(context).textTheme.titleLarge), + Text( + _todayLabel(), + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: scheme.onSurfaceVariant, + ), + ), + ], + ), + actions: [ + 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( + 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) + .toList() + .firstOrNull; + + return Column( + children: [ + // Digest card header + BriefingDigestCard( + message: firstAssistant, + onGenerateNow: _refresh, + ), + + // Divider + 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), + _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}'; + } +} + +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.withValues(alpha: 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.withValues(alpha: 0.38) + : Colors.white, + ), + onPressed: onPressed, + ), + ); + } +} diff --git a/lib/screens/chat/chat_screen.dart b/lib/screens/chat/chat_screen.dart index 1698747..deb6169 100644 --- a/lib/screens/chat/chat_screen.dart +++ b/lib/screens/chat/chat_screen.dart @@ -1,12 +1,9 @@ -import 'dart:math' show min; - import 'package:flutter/material.dart'; -import 'package:flutter_markdown_plus/flutter_markdown_plus.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../core/exceptions.dart'; -import '../../data/models/message.dart'; import '../../providers/chat_provider.dart'; +import '../../widgets/chat_message_bubble.dart'; class ChatScreen extends ConsumerStatefulWidget { final int conversationId; @@ -102,7 +99,7 @@ class _ChatScreenState extends ConsumerState { horizontal: 8, vertical: 12), itemCount: messages.length, itemBuilder: (context, i) => - _MessageBubble(message: messages[i]), + ChatMessageBubble(message: messages[i]), ); }, ), @@ -153,42 +150,3 @@ class _ChatScreenState extends ConsumerState { } } -class _MessageBubble extends StatelessWidget { - final Message message; - const _MessageBubble({required this.message}); - - @override - Widget build(BuildContext context) { - final isUser = message.role == MessageRole.user; - final scheme = Theme.of(context).colorScheme; - - return Align( - alignment: isUser ? Alignment.centerRight : Alignment.centerLeft, - child: Container( - constraints: BoxConstraints( - maxWidth: min(MediaQuery.of(context).size.width * 0.8, 480), - ), - margin: const EdgeInsets.symmetric(vertical: 4), - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - decoration: BoxDecoration( - color: isUser ? scheme.primary : scheme.surfaceContainerHighest, - borderRadius: BorderRadius.only( - topLeft: const Radius.circular(16), - topRight: const Radius.circular(16), - bottomLeft: Radius.circular(isUser ? 16 : 4), - bottomRight: Radius.circular(isUser ? 4 : 16), - ), - ), - child: isUser - ? Text( - message.content, - style: TextStyle(color: scheme.onPrimary), - ) - : MarkdownBody( - data: message.content.isEmpty ? '...' : message.content, - styleSheet: MarkdownStyleSheet.fromTheme(Theme.of(context)), - ), - ), - ); - } -} diff --git a/lib/screens/chat/conversations_list_screen.dart b/lib/screens/chat/conversations_list_screen.dart deleted file mode 100644 index c8183ef..0000000 --- a/lib/screens/chat/conversations_list_screen.dart +++ /dev/null @@ -1,185 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:go_router/go_router.dart'; - -import '../../core/constants.dart'; -import '../../core/exceptions.dart'; -import '../../providers/chat_provider.dart'; -import 'chat_screen.dart'; - -class ConversationsListScreen extends ConsumerStatefulWidget { - const ConversationsListScreen({super.key}); - - @override - ConsumerState createState() => - _ConversationsListScreenState(); -} - -class _ConversationsListScreenState - extends ConsumerState { - int? _selectedConvId; - - Future _newConversation(bool isWide) async { - try { - final conv = await ref.read(conversationsProvider.notifier).create(''); - if (!mounted) return; - if (isWide) { - setState(() => _selectedConvId = conv.id); - } else { - context.push(Routes.chat.replaceFirst(':id', '${conv.id}')); - } - } on AppException catch (e) { - if (mounted) { - ScaffoldMessenger.of(context) - .showSnackBar(SnackBar(content: Text(e.message))); - } - } - } - - @override - Widget build(BuildContext context) { - final convsAsync = ref.watch(conversationsProvider); - final isWide = MediaQuery.of(context).size.width >= 600; - - // Clear stale selection when switching to narrow mode. - if (!isWide && _selectedConvId != null) { - _selectedConvId = null; - } - - return Scaffold( - body: isWide - ? Row( - children: [ - SizedBox( - width: 300, - child: Column( - children: [ - Expanded(child: _buildListPane(convsAsync, isWide)), - const Divider(height: 1), - ListTile( - leading: const Icon(Icons.add), - title: const Text('New conversation'), - onTap: () => _newConversation(isWide), - ), - ], - ), - ), - const VerticalDivider(width: 1), - Expanded(child: _buildDetailPane()), - ], - ) - : _buildListPane(convsAsync, isWide), - floatingActionButton: isWide - ? null - : FloatingActionButton( - heroTag: 'chat_fab', - onPressed: () => _newConversation(isWide), - child: const Icon(Icons.add), - ), - ); - } - - Widget _buildDetailPane() { - if (_selectedConvId == null) { - return const Center(child: Text('Select a conversation to open it.')); - } - return ChatScreen( - key: ValueKey(_selectedConvId), - conversationId: _selectedConvId!, - ); - } - - Widget _buildListPane(AsyncValue convsAsync, bool isWide) { - return convsAsync.when( - loading: () => const Center(child: CircularProgressIndicator()), - error: (_, _) => Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(Icons.cloud_off, size: 48), - const SizedBox(height: 12), - const Text('Could not load conversations.'), - const SizedBox(height: 4), - TextButton( - onPressed: () => ref.invalidate(conversationsProvider), - child: const Text('Retry'), - ), - ], - ), - ), - data: (convs) { - if (convs.isEmpty) { - return const Center( - child: Text('No conversations yet. Tap + to start one.')); - } - return RefreshIndicator( - onRefresh: () => ref.refresh(conversationsProvider.future), - child: ListView.separated( - itemCount: convs.length, - separatorBuilder: (_, _) => const Divider(height: 1), - itemBuilder: (context, i) { - final conv = convs[i]; - return ListTile( - leading: const Icon(Icons.chat_bubble_outline), - title: Text( - conv.title.isNotEmpty ? conv.title : 'New conversation', - ), - subtitle: Text( - conv.updatedAt.toLocal().toString().substring(0, 16), - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - selected: isWide && _selectedConvId == conv.id, - selectedTileColor: - Theme.of(context).colorScheme.secondaryContainer, - onTap: () { - if (isWide) { - setState(() => _selectedConvId = conv.id); - } else { - context.push( - Routes.chat.replaceFirst(':id', '${conv.id}'), - ); - } - }, - onLongPress: () async { - final confirm = await showDialog( - context: context, - builder: (dialogContext) => AlertDialog( - title: const Text('Delete conversation?'), - content: Text( - conv.title.isNotEmpty - ? conv.title - : 'New conversation', - ), - actions: [ - TextButton( - onPressed: () => - Navigator.pop(dialogContext, false), - child: const Text('Cancel'), - ), - TextButton( - onPressed: () => - Navigator.pop(dialogContext, true), - child: const Text('Delete'), - ), - ], - ), - ); - if (confirm == true) { - await ref - .read(conversationsProvider.notifier) - .delete(conv.id); - if (mounted && _selectedConvId == conv.id) { - setState(() => _selectedConvId = null); - } - } - }, - ); - }, - ), - ); - }, - ); - } -} diff --git a/lib/screens/chat/conversations_tab_screen.dart b/lib/screens/chat/conversations_tab_screen.dart new file mode 100644 index 0000000..68f6c7c --- /dev/null +++ b/lib/screens/chat/conversations_tab_screen.dart @@ -0,0 +1,126 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../core/constants.dart'; +import '../../providers/chat_provider.dart'; + +class ConversationsTabScreen extends ConsumerWidget { + const ConversationsTabScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + final convsAsync = ref.watch(conversationsProvider); + + return Scaffold( + appBar: AppBar( + title: Text('Chat', style: theme.textTheme.titleLarge), + actions: [ + IconButton( + icon: const Icon(Icons.add), + tooltip: 'New conversation', + onPressed: () async { + final conv = await ref + .read(conversationsProvider.notifier) + .create('New conversation'); + if (context.mounted) { + context.push(Routes.chat.replaceFirst(':id', '${conv.id}')); + } + }, + ), + ], + ), + body: convsAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => Center(child: Text('Error: $e')), + data: (convs) { + if (convs.isEmpty) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.chat_bubble_outline, + size: 48, color: theme.colorScheme.onSurfaceVariant), + const SizedBox(height: 16), + Text('No conversations yet', + style: theme.textTheme.titleMedium), + const SizedBox(height: 8), + FilledButton.icon( + icon: const Icon(Icons.add), + label: const Text('Start a conversation'), + onPressed: () async { + final conv = await ref + .read(conversationsProvider.notifier) + .create('New conversation'); + if (context.mounted) { + context.push( + Routes.chat.replaceFirst(':id', '${conv.id}')); + } + }, + ), + ], + ), + ); + } + return RefreshIndicator( + onRefresh: () async => ref.invalidate(conversationsProvider), + child: ListView.builder( + itemCount: convs.length, + itemBuilder: (ctx, i) { + final c = convs[i]; + return ListTile( + leading: const Icon(Icons.chat_bubble_outline), + title: Text(c.title, + maxLines: 1, overflow: TextOverflow.ellipsis), + subtitle: Text( + _relativeTime(c.updatedAt), + style: theme.textTheme.labelSmall, + ), + trailing: IconButton( + icon: const Icon(Icons.delete_outline), + onPressed: () => + _confirmDelete(context, ref, c.id, c.title), + ), + onTap: () => + ctx.push(Routes.chat.replaceFirst(':id', '${c.id}')), + ); + }, + ), + ); + }, + ), + ); + } + + Future _confirmDelete( + BuildContext context, WidgetRef ref, int id, String title) async { + final ok = await showDialog( + context: context, + builder: (_) => AlertDialog( + title: const Text('Delete conversation?'), + content: Text('"$title" will be permanently deleted.'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Cancel')), + FilledButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('Delete')), + ], + ), + ); + if (ok == true) { + await ref.read(conversationsProvider.notifier).delete(id); + } + } +} + +String _relativeTime(DateTime dt) { + final diff = DateTime.now().difference(dt); + if (diff.inMinutes < 1) return 'just now'; + if (diff.inHours < 1) return '${diff.inMinutes}m ago'; + if (diff.inDays < 1) return '${diff.inHours}h ago'; + if (diff.inDays < 7) return '${diff.inDays}d ago'; + return '${dt.day}/${dt.month}/${dt.year}'; +} diff --git a/lib/screens/library/library_screen.dart b/lib/screens/library/library_screen.dart new file mode 100644 index 0000000..a66578b --- /dev/null +++ b/lib/screens/library/library_screen.dart @@ -0,0 +1,294 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../core/constants.dart'; +import '../../data/models/note.dart'; +import '../../data/models/task.dart'; +import '../../providers/notes_provider.dart'; +import '../../providers/projects_provider.dart'; +import '../../providers/tasks_provider.dart'; +import '../../widgets/library_item_card.dart'; + +enum _LibraryFilter { all, notes, tasks, projects } + +enum _TaskStatusFilter { all, todo, inProgress, done } + +class LibraryScreen extends ConsumerStatefulWidget { + const LibraryScreen({super.key}); + + @override + ConsumerState createState() => _LibraryScreenState(); +} + +class _LibraryScreenState extends ConsumerState { + _LibraryFilter _filter = _LibraryFilter.all; + _TaskStatusFilter _taskStatus = _TaskStatusFilter.all; + bool _searchActive = false; + String _searchQuery = ''; + final _searchController = TextEditingController(); + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + bool _matchesSearch(String text) { + if (_searchQuery.isEmpty) return true; + return text.toLowerCase().contains(_searchQuery.toLowerCase()); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final notesAsync = ref.watch(notesProvider); + final tasksAsync = ref.watch(tasksProvider); + final projectsAsync = ref.watch(projectsProvider); + + return Scaffold( + appBar: AppBar( + title: _searchActive + ? TextField( + controller: _searchController, + autofocus: true, + decoration: const InputDecoration( + hintText: 'Search…', + border: InputBorder.none, + isDense: true, + ), + onChanged: (q) => setState(() => _searchQuery = q), + ) + : Text('Library', style: theme.textTheme.titleLarge), + actions: [ + IconButton( + icon: Icon(_searchActive ? Icons.close : Icons.search), + onPressed: () => setState(() { + _searchActive = !_searchActive; + if (!_searchActive) { + _searchQuery = ''; + _searchController.clear(); + } + }), + ), + ], + ), + body: Column( + children: [ + // ── Filter pills ────────────────────────────────────────────────── + SingleChildScrollView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.fromLTRB(12, 8, 12, 4), + child: Row( + children: _LibraryFilter.values.map((f) { + final label = switch (f) { + _LibraryFilter.all => 'All', + _LibraryFilter.notes => 'Notes', + _LibraryFilter.tasks => 'Tasks', + _LibraryFilter.projects => 'Projects', + }; + final selected = _filter == f; + return Padding( + padding: const EdgeInsets.only(right: 8), + child: FilterChip( + label: Text(label), + selected: selected, + onSelected: (_) => setState(() { + _filter = f; + _taskStatus = _TaskStatusFilter.all; + }), + selectedColor: + theme.colorScheme.primary.withValues(alpha: 0.18), + checkmarkColor: theme.colorScheme.primary, + ), + ); + }).toList(), + ), + ), + + // ── Task status sub-filter (Tasks pill only) ─────────────────── + if (_filter == _LibraryFilter.tasks) + SingleChildScrollView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.fromLTRB(12, 0, 12, 4), + child: Row( + children: _TaskStatusFilter.values.map((s) { + final label = switch (s) { + _TaskStatusFilter.all => 'All', + _TaskStatusFilter.todo => 'To Do', + _TaskStatusFilter.inProgress => 'In Progress', + _TaskStatusFilter.done => 'Done', + }; + return Padding( + padding: const EdgeInsets.only(right: 8), + child: ChoiceChip( + label: Text(label), + selected: _taskStatus == s, + onSelected: (_) => setState(() => _taskStatus = s), + selectedColor: + theme.colorScheme.secondary.withValues(alpha: 0.15), + ), + ); + }).toList(), + ), + ), + + const Divider(height: 1), + + // ── Content ─────────────────────────────────────────────────────── + Expanded( + child: switch (_filter) { + _LibraryFilter.notes => _buildNotesList(notesAsync), + _LibraryFilter.tasks => _buildTasksList(tasksAsync), + _LibraryFilter.projects => _buildProjectsList(projectsAsync), + _LibraryFilter.all => _buildAllList(notesAsync, tasksAsync), + }, + ), + ], + ), + floatingActionButton: FloatingActionButton( + onPressed: () => _showCreateSheet(context), + child: const Icon(Icons.add), + ), + ); + } + + Widget _buildNotesList(AsyncValue> notesAsync) { + return notesAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => Center(child: Text('Error: $e')), + data: (notes) { + final filtered = notes + .where((n) => _matchesSearch(n.title) || _matchesSearch(n.body)) + .toList(); + if (filtered.isEmpty) { + return const Center(child: Text('No notes found')); + } + return RefreshIndicator( + onRefresh: () async => ref.invalidate(notesProvider), + child: ListView.builder( + itemCount: filtered.length, + itemBuilder: (_, i) => NoteLibraryCard(note: filtered[i]), + ), + ); + }, + ); + } + + Widget _buildTasksList(AsyncValue> tasksAsync) { + return tasksAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => Center(child: Text('Error: $e')), + data: (tasks) { + var filtered = tasks.where((t) => _matchesSearch(t.title)).toList(); + if (_taskStatus != _TaskStatusFilter.all) { + final status = switch (_taskStatus) { + _TaskStatusFilter.todo => TaskStatus.todo, + _TaskStatusFilter.inProgress => TaskStatus.inProgress, + _TaskStatusFilter.done => TaskStatus.done, + _ => TaskStatus.todo, + }; + filtered = filtered.where((t) => t.status == status).toList(); + } + if (filtered.isEmpty) { + return const Center(child: Text('No tasks found')); + } + return RefreshIndicator( + onRefresh: () async => ref.invalidate(tasksProvider), + child: ListView.builder( + itemCount: filtered.length, + itemBuilder: (_, i) => TaskLibraryCard(task: filtered[i]), + ), + ); + }, + ); + } + + Widget _buildProjectsList(AsyncValue> projectsAsync) { + return projectsAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => Center(child: Text('Error: $e')), + data: (projects) { + if (projects.isEmpty) { + return const Center(child: Text('No projects')); + } + return RefreshIndicator( + onRefresh: () async => ref.invalidate(projectsProvider), + child: ListView.builder( + itemCount: projects.length, + itemBuilder: (_, i) => + ProjectLibraryCard(project: projects[i]), + ), + ); + }, + ); + } + + Widget _buildAllList( + AsyncValue> notesAsync, + AsyncValue> tasksAsync, + ) { + final notes = notesAsync.valueOrNull ?? []; + final tasks = tasksAsync.valueOrNull ?? []; + + // Merge and sort by updatedAt desc + final items = <(DateTime, Widget)>[]; + for (final n in notes) { + if (_matchesSearch(n.title) || _matchesSearch(n.body)) { + items.add((n.updatedAt, NoteLibraryCard(note: n))); + } + } + for (final t in tasks) { + if (_matchesSearch(t.title)) { + items.add((t.updatedAt, TaskLibraryCard(task: t))); + } + } + items.sort((a, b) => b.$1.compareTo(a.$1)); + + if (notesAsync.isLoading || tasksAsync.isLoading) { + return const Center(child: CircularProgressIndicator()); + } + if (items.isEmpty) { + return const Center(child: Text('Nothing here yet')); + } + return RefreshIndicator( + onRefresh: () async { + ref.invalidate(notesProvider); + ref.invalidate(tasksProvider); + }, + child: ListView.builder( + itemCount: items.length, + itemBuilder: (_, i) => items[i].$2, + ), + ); + } + + void _showCreateSheet(BuildContext context) { + showModalBottomSheet( + context: context, + builder: (_) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: const Icon(Icons.article_outlined), + title: const Text('New note'), + onTap: () { + Navigator.pop(context); + context.push(Routes.noteNew); + }, + ), + ListTile( + leading: const Icon(Icons.check_box_outlined), + title: const Text('New task'), + onTap: () { + Navigator.pop(context); + context.push(Routes.taskNew); + }, + ), + ], + ), + ), + ); + } +} diff --git a/lib/screens/notes/notes_list_screen.dart b/lib/screens/notes/notes_list_screen.dart deleted file mode 100644 index 34a9652..0000000 --- a/lib/screens/notes/notes_list_screen.dart +++ /dev/null @@ -1,216 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:go_router/go_router.dart'; - -import '../../core/constants.dart'; -import '../../data/models/note.dart'; -import '../../providers/notes_provider.dart'; -import 'note_detail_screen.dart'; - -class NotesListScreen extends ConsumerStatefulWidget { - const NotesListScreen({super.key}); - - @override - ConsumerState createState() => _NotesListScreenState(); -} - -class _NotesListScreenState extends ConsumerState { - bool _showSearch = false; - String _search = ''; - int? _selectedNoteId; - - @override - Widget build(BuildContext context) { - final notesAsync = ref.watch(notesProvider); - final isWide = MediaQuery.of(context).size.width >= 600; - - // Clear stale selection when switching to narrow mode. - if (!isWide && _selectedNoteId != null) { - _selectedNoteId = null; - } - - return Scaffold( - body: isWide - ? Row( - children: [ - SizedBox( - width: 300, - child: Column( - children: [ - Expanded(child: _buildListPane(notesAsync, isWide)), - const Divider(height: 1), - ListTile( - leading: const Icon(Icons.add), - title: const Text('New note'), - onTap: () => context.push(Routes.noteNew), - ), - ], - ), - ), - const VerticalDivider(width: 1), - Expanded(child: _buildDetailPane()), - ], - ) - : _buildListPane(notesAsync, isWide), - floatingActionButton: isWide - ? null - : FloatingActionButton( - heroTag: 'notes_fab', - onPressed: () => context.push(Routes.noteNew), - child: const Icon(Icons.add), - ), - ); - } - - Widget _buildDetailPane() { - if (_selectedNoteId == null) { - return const Center(child: Text('Select a note to read it.')); - } - return NoteDetailScreen( - key: ValueKey(_selectedNoteId), - noteId: _selectedNoteId!, - onDeleted: () => setState(() => _selectedNoteId = null), - ); - } - - Widget _buildListPane(AsyncValue> notesAsync, bool isWide) { - return Stack( - children: [ - Column( - children: [ - if (_showSearch) - Padding( - padding: const EdgeInsets.symmetric(horizontal: 4), - child: Row( - children: [ - Expanded( - child: TextField( - autofocus: true, - decoration: const InputDecoration( - hintText: 'Search notes…', - border: InputBorder.none, - prefixIcon: Icon(Icons.search), - ), - onChanged: (v) => - setState(() => _search = v.trim().toLowerCase()), - ), - ), - IconButton( - icon: const Icon(Icons.close), - tooltip: 'Close search', - onPressed: () => setState(() { - _showSearch = false; - _search = ''; - }), - ), - ], - ), - ), - Expanded( - child: notesAsync.when( - loading: () => - const Center(child: CircularProgressIndicator()), - error: (_, _) => Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(Icons.cloud_off, size: 48), - const SizedBox(height: 12), - const Text('Could not load notes.'), - const SizedBox(height: 4), - TextButton( - onPressed: () => ref.invalidate(notesProvider), - child: const Text('Retry'), - ), - ], - ), - ), - data: (notes) { - final filtered = _search.isEmpty - ? notes - : notes - .where((n) => - n.title.toLowerCase().contains(_search) || - n.body.toLowerCase().contains(_search)) - .toList(); - - if (filtered.isEmpty) { - return Center( - child: Text( - _search.isEmpty - ? 'No notes yet. Tap + to create one.' - : 'No notes match "$_search".', - ), - ); - } - - return RefreshIndicator( - onRefresh: () => ref.refresh(notesProvider.future), - child: ListView.separated( - itemCount: filtered.length, - separatorBuilder: (_, _) => const Divider(height: 1), - itemBuilder: (context, i) { - final note = filtered[i]; - final preview = note.body - .split('\n') - .firstWhere((l) => l.trim().isNotEmpty, - orElse: () => '') - .trim(); - return ListTile( - title: Text(note.title), - subtitle: Text( - preview.isNotEmpty - ? preview - : note.updatedAt - .toLocal() - .toString() - .substring(0, 16), - style: Theme.of(context) - .textTheme - .bodySmall - ?.copyWith( - color: Theme.of(context) - .colorScheme - .onSurfaceVariant, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - selected: isWide && _selectedNoteId == note.id, - selectedTileColor: Theme.of(context) - .colorScheme - .secondaryContainer, - onTap: () { - if (isWide) { - setState(() => _selectedNoteId = note.id); - } else { - context.push( - Routes.noteDetail - .replaceFirst(':id', '${note.id}'), - ); - } - }, - ); - }, - ), - ); - }, - ), - ), - ], - ), - // Floating search button — only visible when search is closed. - if (!_showSearch) - Positioned( - top: 4, - right: 4, - child: IconButton( - icon: const Icon(Icons.search), - tooltip: 'Search', - onPressed: () => setState(() => _showSearch = true), - ), - ), - ], - ); - } -} diff --git a/lib/screens/projects/project_list_screen.dart b/lib/screens/projects/project_list_screen.dart deleted file mode 100644 index 27facf1..0000000 --- a/lib/screens/projects/project_list_screen.dart +++ /dev/null @@ -1,631 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:go_router/go_router.dart'; - -import '../../core/constants.dart'; -import '../../core/exceptions.dart'; -import '../../data/models/milestone.dart'; -import '../../data/models/project.dart'; -import '../../data/models/task.dart'; -import '../../providers/milestones_provider.dart'; -import '../../providers/projects_provider.dart'; -import '../../providers/tasks_provider.dart'; - -class ProjectListScreen extends ConsumerWidget { - const ProjectListScreen({super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final projectsAsync = ref.watch(projectsProvider); - - return Scaffold( - appBar: AppBar(title: const Text('Projects')), - floatingActionButton: FloatingActionButton( - onPressed: () => _showCreateDialog(context, ref), - tooltip: 'New project', - child: const Icon(Icons.add), - ), - body: projectsAsync.when( - loading: () => const Center(child: CircularProgressIndicator()), - error: (e, _) => Center(child: Text('Error: $e')), - data: (projects) { - if (projects.isEmpty) { - return const Center( - child: Text( - 'No projects yet.\nTap + to create one.', - textAlign: TextAlign.center, - ), - ); - } - final active = - projects.where((p) => p.status == 'active').toList(); - final other = - projects.where((p) => p.status != 'active').toList(); - return ListView( - padding: const EdgeInsets.only(bottom: 88), - children: [ - if (active.isNotEmpty) ...[ - _SectionHeader(title: 'Active (${active.length})'), - ...active.map((p) => _ProjectExpansionTile(project: p)), - ], - if (other.isNotEmpty) ...[ - _SectionHeader(title: 'Other'), - ...other.map((p) => _ProjectExpansionTile(project: p)), - ], - ], - ); - }, - ), - ); - } - - void _showCreateDialog(BuildContext context, WidgetRef ref) { - showDialog( - context: context, - builder: (dialogContext) => _CreateProjectDialog( - onCreate: (title, description, goal) async { - try { - await ref - .read(projectsProvider.notifier) - .create(title: title, description: description, goal: goal); - } on AppException catch (e) { - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(e.message)), - ); - } - } - }, - ), - ); - } -} - -// ─── Section header ──────────────────────────────────────────────────────────── - -class _SectionHeader extends StatelessWidget { - final String title; - const _SectionHeader({required this.title}); - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 4), - child: Text( - title, - style: Theme.of(context) - .textTheme - .labelMedium - ?.copyWith(color: Theme.of(context).colorScheme.primary), - ), - ); - } -} - -// ─── Project expansion tile ──────────────────────────────────────────────────── - -class _ProjectExpansionTile extends ConsumerStatefulWidget { - final Project project; - const _ProjectExpansionTile({required this.project}); - - @override - ConsumerState<_ProjectExpansionTile> createState() => - _ProjectExpansionTileState(); -} - -class _ProjectExpansionTileState - extends ConsumerState<_ProjectExpansionTile> { - bool _expanded = false; - - Color _statusColor(BuildContext context) => switch (widget.project.status) { - 'completed' => Colors.green, - 'archived' => Colors.grey, - _ => Theme.of(context).colorScheme.primary, - }; - - void _showOptions(BuildContext context) { - showModalBottomSheet( - context: context, - builder: (_) => SafeArea( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - ListTile( - leading: const Icon(Icons.check_circle_outline), - title: const Text('Mark completed'), - onTap: () async { - Navigator.pop(context); - await ref.read(projectsProvider.notifier).updateProject( - widget.project.id, - {'status': 'completed'}, - ); - }, - ), - ListTile( - leading: const Icon(Icons.archive_outlined), - title: const Text('Archive'), - onTap: () async { - Navigator.pop(context); - await ref.read(projectsProvider.notifier).updateProject( - widget.project.id, - {'status': 'archived'}, - ); - }, - ), - ListTile( - leading: Icon(Icons.delete_outline, - color: Theme.of(context).colorScheme.error), - title: Text('Delete', - style: TextStyle( - color: Theme.of(context).colorScheme.error)), - onTap: () async { - Navigator.pop(context); - final confirm = await showDialog( - context: context, - builder: (dialogContext) => AlertDialog( - title: const Text('Delete project?'), - content: const Text( - 'Notes and tasks will be unlinked, not deleted.'), - actions: [ - TextButton( - onPressed: () => - Navigator.pop(dialogContext, false), - child: const Text('Cancel'), - ), - TextButton( - onPressed: () => - Navigator.pop(dialogContext, true), - child: const Text('Delete'), - ), - ], - ), - ); - if (confirm == true && context.mounted) { - await ref - .read(projectsProvider.notifier) - .delete(widget.project.id); - } - }, - ), - ], - ), - ), - ); - } - - @override - Widget build(BuildContext context) { - final statusColor = _statusColor(context); - final tasksAsync = ref.watch(tasksProvider); - final unfinished = tasksAsync.valueOrNull - ?.where((t) => - t.projectId == widget.project.id && - t.status != TaskStatus.done) - .toList() ?? - []; - - final subtitle = unfinished.isEmpty - ? (widget.project.description != null && - widget.project.description!.isNotEmpty - ? widget.project.description! - : null) - : '${unfinished.length} task${unfinished.length == 1 ? '' : 's'} in progress'; - - return ExpansionTile( - key: PageStorageKey('project-${widget.project.id}'), - initiallyExpanded: false, - onExpansionChanged: (v) => setState(() => _expanded = v), - leading: CircleAvatar( - backgroundColor: statusColor.withValues(alpha: 0.15), - child: Icon(Icons.folder_outlined, color: statusColor, size: 20), - ), - title: GestureDetector( - onLongPress: () => _showOptions(context), - child: Text(widget.project.title), - ), - subtitle: subtitle != null - ? Text( - subtitle, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodySmall, - ) - : null, - trailing: _StatusChip(status: widget.project.status), - children: [ - if (_expanded) - _ProjectTaskList( - project: widget.project, - unfinishedTasks: unfinished, - ), - ], - ); - } -} - -// ─── Expanded task list grouped by milestone ─────────────────────────────────── - -class _ProjectTaskList extends ConsumerWidget { - final Project project; - final List unfinishedTasks; - const _ProjectTaskList({ - required this.project, - required this.unfinishedTasks, - }); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final milestonesAsync = - ref.watch(projectMilestonesProvider(project.id)); - - return milestonesAsync.when( - loading: () => const Padding( - padding: EdgeInsets.all(16), - child: Center(child: CircularProgressIndicator(strokeWidth: 2)), - ), - error: (e, _) => Padding( - padding: const EdgeInsets.all(16), - child: Text('Error loading milestones: $e', - style: TextStyle( - color: Theme.of(context).colorScheme.error)), - ), - data: (milestones) { - // Only active milestones in order - final activeMilestones = milestones - .where((m) => m.status == 'active') - .toList() - ..sort((a, b) => a.orderIndex.compareTo(b.orderIndex)); - - if (unfinishedTasks.isEmpty) { - return _EmptyProjectContent(project: project); - } - - // Build milestone → tasks map - final Map> grouped = {}; - for (final task in unfinishedTasks) { - grouped.putIfAbsent(task.milestoneId, () => []).add(task); - } - - final widgets = []; - - // Milestone groups (in order) - for (final ms in activeMilestones) { - final tasks = grouped[ms.id]; - if (tasks == null || tasks.isEmpty) continue; - widgets.add(_MilestoneHeader(milestone: ms)); - for (final task in tasks) { - widgets.add(_TaskRow(task: task)); - } - } - - // No-milestone group - final noMilestoneTasks = grouped[null] ?? []; - if (noMilestoneTasks.isNotEmpty) { - if (activeMilestones.isNotEmpty) { - widgets.add(const _NoMilestoneHeader()); - } - for (final task in noMilestoneTasks) { - widgets.add(_TaskRow(task: task)); - } - } - - widgets.add(_AddTaskRow(project: project)); - - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: widgets, - ); - }, - ); - } -} - -class _EmptyProjectContent extends StatelessWidget { - final Project project; - const _EmptyProjectContent({required this.project}); - - @override - Widget build(BuildContext context) { - return Column( - children: [ - Padding( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 4), - child: Text( - 'No open tasks.', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.outline, - ), - ), - ), - _AddTaskRow(project: project), - ], - ); - } -} - -// ─── Milestone header ────────────────────────────────────────────────────────── - -class _MilestoneHeader extends StatelessWidget { - final Milestone milestone; - const _MilestoneHeader({required this.milestone}); - - @override - Widget build(BuildContext context) { - final pct = milestone.total == 0 ? 0.0 : milestone.pct / 100.0; - final colorScheme = Theme.of(context).colorScheme; - return Padding( - padding: const EdgeInsets.fromLTRB(56, 12, 16, 2), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - const Icon(Icons.flag_outlined, size: 14), - const SizedBox(width: 4), - Expanded( - child: Text( - milestone.title, - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: colorScheme.secondary, - fontWeight: FontWeight.w600, - ), - ), - ), - Text( - '${milestone.completed}/${milestone.total}', - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: colorScheme.outline, - ), - ), - ], - ), - const SizedBox(height: 4), - LinearProgressIndicator( - value: pct, - minHeight: 3, - borderRadius: BorderRadius.circular(2), - backgroundColor: - colorScheme.secondaryContainer.withValues(alpha: 0.4), - valueColor: - AlwaysStoppedAnimation(colorScheme.secondary), - ), - ], - ), - ); - } -} - -class _NoMilestoneHeader extends StatelessWidget { - const _NoMilestoneHeader(); - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.fromLTRB(56, 12, 16, 2), - child: Text( - 'No milestone', - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: Theme.of(context).colorScheme.outline, - ), - ), - ); - } -} - -// ─── Task row ────────────────────────────────────────────────────────────────── - -class _TaskRow extends StatelessWidget { - final Task task; - const _TaskRow({required this.task}); - - @override - Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - final isInProgress = task.status == TaskStatus.inProgress; - final dotColor = isInProgress ? colorScheme.primary : colorScheme.outline; - final priorityColor = switch (task.priority) { - TaskPriority.high => Colors.red, - TaskPriority.medium => Colors.orange, - TaskPriority.low => Colors.blue, - _ => null, - }; - - return ListTile( - dense: true, - contentPadding: const EdgeInsets.fromLTRB(56, 0, 16, 0), - leading: Icon( - isInProgress ? Icons.radio_button_checked : Icons.radio_button_unchecked, - size: 18, - color: dotColor, - ), - title: Text(task.title, maxLines: 2, overflow: TextOverflow.ellipsis), - subtitle: task.dueDate != null - ? Text( - _formatDue(task.dueDate!), - style: TextStyle( - fontSize: 11, - color: _isDueOverdue(task.dueDate!) - ? colorScheme.error - : colorScheme.outline, - ), - ) - : null, - trailing: priorityColor != null - ? Container( - width: 6, - height: 6, - decoration: BoxDecoration( - color: priorityColor, - shape: BoxShape.circle, - ), - ) - : null, - onTap: () => context.push( - Routes.taskEdit.replaceFirst(':id', '${task.id}'), - ), - ); - } - - String _formatDue(DateTime due) { - final now = DateTime.now(); - final diff = due.difference(DateTime(now.year, now.month, now.day)).inDays; - if (diff == 0) return 'Due today'; - if (diff == 1) return 'Due tomorrow'; - if (diff < 0) return 'Overdue ${(-diff)} day${(-diff) == 1 ? '' : 's'}'; - if (diff < 7) return 'Due in $diff days'; - return 'Due ${due.month}/${due.day}'; - } - - bool _isDueOverdue(DateTime due) { - final now = DateTime.now(); - return due.isBefore(DateTime(now.year, now.month, now.day)); - } -} - -// ─── Add task row ────────────────────────────────────────────────────────────── - -class _AddTaskRow extends StatelessWidget { - final Project project; - const _AddTaskRow({required this.project}); - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.fromLTRB(48, 4, 16, 8), - child: TextButton.icon( - onPressed: () => context.push('${Routes.taskNew}?projectId=${project.id}'), - icon: const Icon(Icons.add, size: 16), - label: const Text('New task'), - style: TextButton.styleFrom( - foregroundColor: Theme.of(context).colorScheme.outline, - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - textStyle: const TextStyle(fontSize: 13), - ), - ), - ); - } -} - -// ─── Status chip ─────────────────────────────────────────────────────────────── - -class _StatusChip extends StatelessWidget { - final String status; - const _StatusChip({required this.status}); - - @override - Widget build(BuildContext context) { - final (label, color) = switch (status) { - 'completed' => ('Done', Colors.green), - 'archived' => ('Archived', Colors.grey), - _ => ('Active', Theme.of(context).colorScheme.primary), - }; - return Chip( - label: Text(label, style: const TextStyle(fontSize: 11)), - padding: EdgeInsets.zero, - materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, - side: BorderSide(color: color.withValues(alpha: 0.4)), - backgroundColor: color.withValues(alpha: 0.1), - labelStyle: TextStyle(color: color), - ); - } -} - -// ─── Create project dialog ───────────────────────────────────────────────────── - -class _CreateProjectDialog extends StatefulWidget { - final Future Function(String title, String? description, String? goal) - onCreate; - - const _CreateProjectDialog({required this.onCreate}); - - @override - State<_CreateProjectDialog> createState() => _CreateProjectDialogState(); -} - -class _CreateProjectDialogState extends State<_CreateProjectDialog> { - final _titleController = TextEditingController(); - final _descController = TextEditingController(); - final _goalController = TextEditingController(); - bool _saving = false; - - @override - void dispose() { - _titleController.dispose(); - _descController.dispose(); - _goalController.dispose(); - super.dispose(); - } - - Future _submit() async { - final title = _titleController.text.trim(); - if (title.isEmpty) return; - setState(() => _saving = true); - try { - await widget.onCreate( - title, - _descController.text.trim().isEmpty ? null : _descController.text.trim(), - _goalController.text.trim().isEmpty ? null : _goalController.text.trim(), - ); - if (mounted) Navigator.pop(context); - } finally { - if (mounted) setState(() => _saving = false); - } - } - - @override - Widget build(BuildContext context) { - return AlertDialog( - title: const Text('New Project'), - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - TextField( - controller: _titleController, - decoration: const InputDecoration( - labelText: 'Title', - border: OutlineInputBorder(), - ), - autofocus: true, - textInputAction: TextInputAction.next, - ), - const SizedBox(height: 12), - TextField( - controller: _descController, - decoration: const InputDecoration( - labelText: 'Description (optional)', - border: OutlineInputBorder(), - ), - maxLines: 2, - textInputAction: TextInputAction.next, - ), - const SizedBox(height: 12), - TextField( - controller: _goalController, - decoration: const InputDecoration( - labelText: 'Goal (optional)', - border: OutlineInputBorder(), - ), - textInputAction: TextInputAction.done, - onSubmitted: (_) => _submit(), - ), - ], - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: const Text('Cancel'), - ), - FilledButton( - onPressed: _saving ? null : _submit, - child: _saving - ? const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Text('Create'), - ), - ], - ); - } -} diff --git a/lib/screens/quick_capture/quick_capture_screen.dart b/lib/screens/quick_capture/quick_capture_screen.dart deleted file mode 100644 index 15dbeee..0000000 --- a/lib/screens/quick_capture/quick_capture_screen.dart +++ /dev/null @@ -1,151 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:go_router/go_router.dart'; - -import '../../core/exceptions.dart'; -import '../../providers/api_client_provider.dart'; -import '../../providers/notes_provider.dart'; -import '../../providers/tasks_provider.dart'; - -class QuickCaptureScreen extends ConsumerStatefulWidget { - const QuickCaptureScreen({super.key}); - - @override - ConsumerState createState() => - _QuickCaptureScreenState(); -} - -class _QuickCaptureScreenState extends ConsumerState { - final _controller = TextEditingController(); - final _focusNode = FocusNode(); - bool _loading = false; - - @override - void dispose() { - _controller.dispose(); - _focusNode.dispose(); - super.dispose(); - } - - Future _send() async { - final text = _controller.text.trim(); - if (text.isEmpty) return; - setState(() => _loading = true); - try { - final result = - await ref.read(quickCaptureApiProvider).capture(text); - // Invalidate the relevant provider so the list screen re-fetches. - switch (result.type) { - case 'note': - ref.invalidate(notesProvider); - case 'task': - case 'todo': - ref.invalidate(tasksProvider); - } - - if (mounted) { - // Use the server's human-readable message if available, else compose one. - final msg = result.message.isNotEmpty - ? result.message - : '${_typeLabel(result.type)} created: ${result.title}'; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(msg), - behavior: SnackBarBehavior.floating, - ), - ); - context.pop(); - } - } on AppException catch (e) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(e.message), - behavior: SnackBarBehavior.floating, - ), - ); - } - } finally { - if (mounted) setState(() => _loading = false); - } - } - - String _typeLabel(String type) => switch (type) { - 'note' => 'Note', - 'task' => 'Task', - 'event' => 'Event', - 'todo' => 'To-do', - _ => type, - }; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - - return Scaffold( - appBar: AppBar(title: const Text('Quick Capture')), - body: Padding( - padding: const EdgeInsets.all(20), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text( - 'What\'s on your mind?', - style: theme.textTheme.titleMedium, - ), - const SizedBox(height: 4), - Text( - 'Describe a note, task, event, or research item — Fabled will figure out the rest.', - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: 16), - TextField( - controller: _controller, - focusNode: _focusNode, - autofocus: true, - enabled: !_loading, - maxLines: 6, - minLines: 3, - keyboardType: TextInputType.multiline, - decoration: InputDecoration( - hintText: - 'e.g. "Remind me to call the dentist next Monday" or "Note: project meeting went well, key points were..."', - border: const OutlineInputBorder(), - alignLabelWithHint: true, - suffixIcon: _controller.text.isNotEmpty - ? IconButton( - icon: const Icon(Icons.clear), - onPressed: () { - _controller.clear(); - setState(() {}); - }, - ) - : null, - ), - onChanged: (_) => setState(() {}), - ), - const SizedBox(height: 16), - FilledButton.icon( - onPressed: (_loading || _controller.text.trim().isEmpty) - ? null - : _send, - icon: _loading - ? const SizedBox( - width: 18, - height: 18, - child: CircularProgressIndicator( - strokeWidth: 2, - color: Colors.white, - ), - ) - : const Icon(Icons.auto_awesome), - label: Text(_loading ? 'Processing...' : 'Capture'), - ), - ], - ), - ), - ); - } -} diff --git a/lib/screens/tasks/tasks_list_screen.dart b/lib/screens/tasks/tasks_list_screen.dart deleted file mode 100644 index 9173f9e..0000000 --- a/lib/screens/tasks/tasks_list_screen.dart +++ /dev/null @@ -1,199 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:go_router/go_router.dart'; - -import '../../core/constants.dart'; -import '../../data/models/task.dart'; -import '../../providers/tasks_provider.dart'; - -class TasksListScreen extends ConsumerStatefulWidget { - const TasksListScreen({super.key}); - - @override - ConsumerState createState() => _TasksListScreenState(); -} - -class _TasksListScreenState extends ConsumerState - with SingleTickerProviderStateMixin { - late final TabController _tabs; - bool _showSearch = false; - String _search = ''; - - @override - void initState() { - super.initState(); - _tabs = TabController(length: 3, vsync: this); - } - - @override - void dispose() { - _tabs.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final tasksAsync = ref.watch(tasksProvider); - - return Scaffold( - body: Column( - children: [ - // Search field — appears above the tabs when active - if (_showSearch) - Padding( - padding: const EdgeInsets.symmetric(horizontal: 4), - child: Row( - children: [ - Expanded( - child: TextField( - autofocus: true, - decoration: const InputDecoration( - hintText: 'Search tasks…', - border: InputBorder.none, - prefixIcon: Icon(Icons.search), - ), - onChanged: (v) => - setState(() => _search = v.trim().toLowerCase()), - ), - ), - IconButton( - icon: const Icon(Icons.close), - tooltip: 'Close search', - onPressed: () => setState(() { - _showSearch = false; - _search = ''; - }), - ), - ], - ), - ), - // Tab bar row — search icon sits to the right of the tabs - Row( - children: [ - Expanded( - child: TabBar( - controller: _tabs, - tabs: const [ - Tab(text: 'To Do'), - Tab(text: 'In Progress'), - Tab(text: 'Done'), - ], - ), - ), - if (!_showSearch) - IconButton( - icon: const Icon(Icons.search), - tooltip: 'Search', - onPressed: () => setState(() => _showSearch = true), - ), - ], - ), - // Content - Expanded( - child: tasksAsync.when( - loading: () => const Center(child: CircularProgressIndicator()), - error: (_, _) => Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(Icons.cloud_off, size: 48), - const SizedBox(height: 12), - const Text('Could not load tasks.'), - const SizedBox(height: 4), - TextButton( - onPressed: () => ref.invalidate(tasksProvider), - child: const Text('Retry'), - ), - ], - ), - ), - data: (tasks) { - final filtered = _search.isEmpty - ? tasks - : tasks - .where((t) => - t.title.toLowerCase().contains(_search) || - (t.description ?? '') - .toLowerCase() - .contains(_search)) - .toList(); - - final todo = filtered - .where((t) => t.status == TaskStatus.todo) - .toList(); - final inProgress = filtered - .where((t) => t.status == TaskStatus.inProgress) - .toList(); - final done = filtered - .where((t) => t.status == TaskStatus.done) - .toList(); - - return RefreshIndicator( - onRefresh: () => ref.refresh(tasksProvider.future), - child: TabBarView( - controller: _tabs, - children: [ - _TaskList(tasks: todo, search: _search), - _TaskList(tasks: inProgress, search: _search), - _TaskList(tasks: done, search: _search), - ], - ), - ); - }, - ), - ), - ], - ), - floatingActionButton: FloatingActionButton( - heroTag: 'tasks_fab', - onPressed: () => context.push(Routes.taskNew), - child: const Icon(Icons.add), - ), - ); - } -} - -class _TaskList extends ConsumerWidget { - final List tasks; - final String search; - const _TaskList({required this.tasks, this.search = ''}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - if (tasks.isEmpty) { - return Center( - child: Text( - search.isEmpty ? 'No tasks here.' : 'No tasks match "$search".', - ), - ); - } - return ListView.separated( - itemCount: tasks.length, - separatorBuilder: (_, _) => const Divider(height: 1), - itemBuilder: (context, i) { - final task = tasks[i]; - return ListTile( - leading: _priorityIcon(task.priority), - title: Text(task.title), - subtitle: task.dueDate != null - ? Text( - 'Due: ${task.dueDate!.toLocal().toString().substring(0, 10)}') - : null, - onTap: () => context.push( - Routes.taskEdit.replaceFirst(':id', '${task.id}'), - ), - ); - }, - ); - } - - Widget _priorityIcon(TaskPriority p) { - final color = switch (p) { - TaskPriority.high => Colors.red, - TaskPriority.medium => Colors.orange, - TaskPriority.low => Colors.green, - TaskPriority.none => Colors.grey, - }; - return Icon(Icons.flag, color: color); - } -} diff --git a/lib/widgets/briefing_digest_card.dart b/lib/widgets/briefing_digest_card.dart new file mode 100644 index 0000000..287e042 --- /dev/null +++ b/lib/widgets/briefing_digest_card.dart @@ -0,0 +1,133 @@ +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 createState() => _BriefingDigestCardState(); +} + +class _BriefingDigestCardState extends State { + 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.withValues(alpha: 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) { + return ConstrainedBox( + constraints: BoxConstraints(maxHeight: maxLines * 20.0), + child: ClipRect(child: MarkdownBody(data: data)), + ); + } +} diff --git a/lib/widgets/chat_message_bubble.dart b/lib/widgets/chat_message_bubble.dart new file mode 100644 index 0000000..19ed6ea --- /dev/null +++ b/lib/widgets/chat_message_bubble.dart @@ -0,0 +1,79 @@ +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.withValues(alpha: 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.withValues(alpha: 0.75) + : scheme.onSurface, + fontSize: 14, + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/widgets/library_item_card.dart b/lib/widgets/library_item_card.dart new file mode 100644 index 0000000..bfa29d0 --- /dev/null +++ b/lib/widgets/library_item_card.dart @@ -0,0 +1,271 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../core/constants.dart'; +import '../data/models/note.dart'; +import '../data/models/project.dart'; +import '../data/models/task.dart'; +import '../providers/tasks_provider.dart'; + +// ── Note card ──────────────────────────────────────────────────────────────── + +class NoteLibraryCard extends StatelessWidget { + final Note note; + const NoteLibraryCard({super.key, required this.note}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Card( + margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + child: InkWell( + onTap: () => context + .push(Routes.noteDetail.replaceFirst(':id', '${note.id}')), + borderRadius: BorderRadius.circular(14), + child: Padding( + padding: const EdgeInsets.fromLTRB(14, 12, 14, 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.article_outlined, + size: 15, color: theme.colorScheme.onSurfaceVariant), + const SizedBox(width: 6), + Expanded( + child: Text( + note.title.isNotEmpty ? note.title : 'Untitled', + style: theme.textTheme.titleSmall, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + Text( + _relativeTime(note.updatedAt), + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant), + ), + ], + ), + if (note.body.isNotEmpty) ...[ + const SizedBox(height: 4), + Text( + note.body.replaceAll('\n', ' '), + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + if (note.tags.isNotEmpty) ...[ + const SizedBox(height: 6), + Wrap( + spacing: 4, + runSpacing: 2, + children: note.tags + .take(4) + .map((t) => Chip( + label: Text(t), + materialTapTargetSize: + MaterialTapTargetSize.shrinkWrap, + padding: EdgeInsets.zero, + visualDensity: VisualDensity.compact, + )) + .toList(), + ), + ], + ], + ), + ), + ), + ); + } +} + +// ── Task card ──────────────────────────────────────────────────────────────── + +class TaskLibraryCard extends ConsumerWidget { + final Task task; + const TaskLibraryCard({super.key, required this.task}); + + Color _priorityColor(BuildContext context) { + return switch (task.priority) { + TaskPriority.high => const Color(0xFFEF4444), + TaskPriority.medium => const Color(0xFFF59E0B), + _ => Theme.of(context).colorScheme.onSurfaceVariant, + }; + } + + IconData get _statusIcon => switch (task.status) { + TaskStatus.done => Icons.check_circle, + TaskStatus.inProgress => Icons.timelapse, + _ => Icons.radio_button_unchecked, + }; + + Color _statusColor(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return switch (task.status) { + TaskStatus.done => const Color(0xFF22C55E), + TaskStatus.inProgress => cs.primary, + _ => cs.onSurfaceVariant, + }; + } + + TaskStatus get _nextStatus => switch (task.status) { + TaskStatus.todo => TaskStatus.inProgress, + TaskStatus.inProgress => TaskStatus.done, + _ => TaskStatus.todo, + }; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + return Card( + margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + child: InkWell( + onTap: () => context + .push(Routes.taskEdit.replaceFirst(':id', '${task.id}')), + borderRadius: BorderRadius.circular(14), + child: Padding( + padding: const EdgeInsets.fromLTRB(8, 10, 14, 10), + child: Row( + children: [ + // Status cycle button + IconButton( + icon: Icon(_statusIcon, color: _statusColor(context)), + onPressed: () => ref + .read(tasksProvider.notifier) + .updateTask(task.id, {'status': _nextStatus.value}), + tooltip: 'Cycle status', + visualDensity: VisualDensity.compact, + ), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + task.title.isNotEmpty ? task.title : 'Untitled', + style: theme.textTheme.titleSmall?.copyWith( + decoration: task.status == TaskStatus.done + ? TextDecoration.lineThrough + : null, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (task.dueDate != null) ...[ + const SizedBox(height: 2), + Text( + 'Due ${_formatDate(task.dueDate!)}', + style: theme.textTheme.labelSmall?.copyWith( + color: task.dueDate!.isBefore(DateTime.now()) && + task.status != TaskStatus.done + ? const Color(0xFFEF4444) + : theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ], + ), + ), + if (task.priority != TaskPriority.none && + task.priority != TaskPriority.low) + Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: _priorityColor(context), + shape: BoxShape.circle, + ), + ), + ], + ), + ), + ), + ); + } +} + +// ── Project card ───────────────────────────────────────────────────────────── + +class ProjectLibraryCard extends StatelessWidget { + final Project project; + const ProjectLibraryCard({super.key, required this.project}); + + Color _parseColor(String? hex) { + if (hex == null || hex.isEmpty) return const Color(0xFF6366F1); + try { + return Color(int.parse(hex.replaceFirst('#', '0xFF'))); + } catch (_) { + return const Color(0xFF6366F1); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final color = _parseColor(project.color); + return Card( + margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: () {}, // No project detail screen in this app + borderRadius: BorderRadius.circular(14), + child: Row( + children: [ + // Colour strip + Container(width: 6, height: 64, color: color), + const SizedBox(width: 12), + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + project.title, + style: theme.textTheme.titleSmall, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (project.description?.isNotEmpty == true) ...[ + const SizedBox(height: 2), + Text( + project.description!, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ], + ), + ), + ), + const SizedBox(width: 12), + ], + ), + ), + ); + } +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +String _relativeTime(DateTime dt) { + final diff = DateTime.now().difference(dt); + if (diff.inMinutes < 1) return 'just now'; + if (diff.inHours < 1) return '${diff.inMinutes}m ago'; + if (diff.inDays < 1) return '${diff.inHours}h ago'; + if (diff.inDays < 7) return '${diff.inDays}d ago'; + return _formatDate(dt); +} + +String _formatDate(DateTime dt) { + const months = [ + 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', + 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec' + ]; + return '${months[dt.month - 1]} ${dt.day}'; +} diff --git a/pubspec.lock b/pubspec.lock index 45fecfc..65c7013 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -280,6 +280,14 @@ packages: url: "https://pub.dev" source: hosted version: "14.8.1" + google_fonts: + dependency: "direct main" + description: + name: google_fonts + sha256: ba03d03bcaa2f6cb7bd920e3b5027181db75ab524f8891c8bc3aa603885b8055 + url: "https://pub.dev" + source: hosted + version: "6.3.3" hooks: dependency: transitive description: @@ -372,10 +380,10 @@ packages: dependency: transitive description: name: matcher - sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6" + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.dev" source: hosted - version: "0.12.18" + version: "0.12.19" material_color_utilities: dependency: transitive description: @@ -713,10 +721,10 @@ packages: dependency: transitive description: name: test_api - sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636" + sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" url: "https://pub.dev" source: hosted - version: "0.7.9" + version: "0.7.10" typed_data: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index bada420..654fc03 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -24,6 +24,7 @@ dependencies: open_file: ^3.3.2 flutter_inappwebview: ^6.1.5 flutter_markdown_plus: ^1.0.7 + google_fonts: ^6.2.1 dev_dependencies: flutter_test: