From ef4872e24a1979a227e7151220d4b6b1a08656cd Mon Sep 17 00:00:00 2001 From: bvandeusen Date: Mon, 9 Mar 2026 21:44:37 -0400 Subject: [PATCH 1/7] =?UTF-8?q?Bump=20actions/checkout=20v4=20=E2=86=92=20?= =?UTF-8?q?v6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .forgejo/workflows/build.yml | 4 ++-- .forgejo/workflows/ci.yml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.forgejo/workflows/build.yml b/.forgejo/workflows/build.yml index 3406723..e55c8d4 100644 --- a/.forgejo/workflows/build.yml +++ b/.forgejo/workflows/build.yml @@ -28,7 +28,7 @@ jobs: container: image: ghcr.io/cirruslabs/flutter:stable steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Install dependencies run: flutter pub get @@ -46,7 +46,7 @@ jobs: fi - name: Upload artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: name: ${{ steps.artifact.outputs.name }} path: build/app/outputs/flutter-apk/app-release.apk diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index c92249f..c7bb658 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -25,7 +25,7 @@ jobs: container: image: ghcr.io/cirruslabs/flutter:stable steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Install dependencies run: flutter pub get From 3bd9c64477f9412818b5e668bb045e45dd9ffe05 Mon Sep 17 00:00:00 2001 From: bvandeusen Date: Wed, 11 Mar 2026 22:18:59 -0400 Subject: [PATCH 2/7] docs: fabled app overhaul design spec --- .../2026-03-11-fabled-app-overhaul-design.md | 253 ++++++++++++++++++ 1 file changed, 253 insertions(+) create mode 100644 docs/superpowers/specs/2026-03-11-fabled-app-overhaul-design.md diff --git a/docs/superpowers/specs/2026-03-11-fabled-app-overhaul-design.md b/docs/superpowers/specs/2026-03-11-fabled-app-overhaul-design.md new file mode 100644 index 0000000..1f72b80 --- /dev/null +++ b/docs/superpowers/specs/2026-03-11-fabled-app-overhaul-design.md @@ -0,0 +1,253 @@ +# Fabled App Overhaul — Design Spec + +**Date:** 2026-03-11 +**Project:** `/home/bvandeusen/Nextcloud/Projects/fabled_app` + +## Goal + +Reposition the Flutter Android app from a general-purpose mirror of the web app into a focused mobile companion: the Daily Briefing is the primary experience, Quick Capture is the secondary utility, and Notes/Tasks/Projects are a browsable library — secondary to both. + +--- + +## Navigation & Shell + +Three-tab shell replacing the current four-tab (Notes · Tasks · Projects · Chat) structure. + +| Tab | Icon | Screen | Route | +|-----|------|--------|-------| +| Briefing | `Icons.wb_sunny_outlined` / `Icons.wb_sunny` | `BriefingScreen` | `/briefing` | +| Library | `Icons.library_books_outlined` / `Icons.library_books` | `LibraryScreen` | `/library` | +| Chat | `Icons.chat_bubble_outline` / `Icons.chat_bubble` | `ConversationsListScreen` (inline) | `/conversations` | + +The `_QuickCaptureBar` remains pinned above the shell on all three tabs. The settings icon stays in the capture bar row. + +**Briefing is the initial route** — the app opens directly to the briefing tab on every launch. + +**Wide layout (≥ 600dp):** `NavigationRail` on the left (as today), same 3 destinations. + +### Dead Code Removed + +**Screens deleted:** +- `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` + +**Screens kept:** +- `lib/screens/notes/note_detail_screen.dart` +- `lib/screens/notes/note_edit_screen.dart` +- `lib/screens/tasks/task_edit_screen.dart` +- `lib/screens/chat/chat_screen.dart` +- All auth, settings, setup, splash screens + +--- + +## Theme + +Custom `ColorScheme` matching the main web app's "Illuminated Transcript" palette, in `lib/core/theme.dart`. System light/dark preference respected. + +### Dark theme +| Token | Value | +|-------|-------| +| `background` | `#111113` | +| `surface` | `#18181c` | +| `primary` | `#6366f1` | +| `onSurface` | `#e8e8f0` | +| `onSurfaceVariant` (muted) | `#8888a8` | + +### Light theme +| Token | Value | +|-------|-------| +| `background` | `#f4f4f8` | +| `surface` | `#ffffff` | +| `primary` | `#4f46e5` | +| `onSurface` | `#18181c` | +| `onSurfaceVariant` (muted) | `#6b6b88` | + +### Typography +- Add `google_fonts` to `pubspec.yaml` +- `headlineMedium`, `titleLarge`, `titleMedium` → `GoogleFonts.fraunces()` +- Body styles → system default (unchanged) + +### Buttons & Cards +- Primary action buttons: `BoxDecoration` with `LinearGradient(135°, #6366f1, #4f46e5)`; applied to send buttons in capture bar and briefing reply bar +- Cards: `borderRadius: 14`, subtle elevation shadow, no explicit border + +--- + +## BriefingScreen + +**File:** `lib/screens/briefing/briefing_screen.dart` + +The app's primary screen. Opens on launch. + +### Layout + +``` +AppBar: + title: "Briefing" (Fraunces) + subtitle: today's date ("Wednesday, March 11") + actions: [↻ refresh button] + +Body (scrollable column): + ┌─ DigestCard ──────────────────────┐ + │ ☀ Good morning — Mar 11 │ + │ [first assistant message, │ + │ truncated to 5 lines] │ + │ [Show more ↓] │ + └───────────────────────────────────┘ + + ─── Conversation ─── + + [scrollable message list] + [streaming bubble while generating] + +Bottom pinned: + [ Reply to your briefing… ] [➤] +``` + +### Digest Card +- Extracted widget: `lib/widgets/briefing_digest_card.dart` +- Shows the content of the first `assistant` message from today's conversation +- Truncated to 5 lines by default; `[Show more]` expands with `AnimatedSize` +- If no briefing exists yet: "No briefing yet today" placeholder + "Generate now" button + +### Conversation +- Message bubbles reuse the same widget used in `ChatScreen`; extracted to `lib/widgets/chat_message_bubble.dart` (shared between both screens) +- User messages: right-aligned, primary colour container +- Assistant messages: left-aligned, surface container with left accent border (indigo, 2dp) +- Streaming: a streaming bubble appears at the bottom of the list while SSE is active + +### Reply Bar +- Always visible (pinned to bottom, above system nav bar) +- Send button: indigo gradient, disabled when input is empty or streaming +- Submitting a reply uses the existing SSE chat endpoint with today's briefing `conversation_id` + +### Overflow Menu (`···`) +- "View past briefings" → pushes `BriefingHistoryScreen` (simple date list, read-only — no reply bar) + +### Refresh Button +- Calls `POST /api/briefing/trigger` with `{"slot": "compilation"}` +- Shows `CircularProgressIndicator` in the AppBar while in-flight +- Reloads conversation on completion + +### New Files +- `lib/screens/briefing/briefing_screen.dart` +- `lib/screens/briefing/briefing_history_screen.dart` +- `lib/widgets/briefing_digest_card.dart` +- `lib/widgets/chat_message_bubble.dart` (extracted from ChatScreen) +- `lib/data/api/briefing_api.dart` — `getToday()`, `getMessages(id)`, `triggerSlot(slot)` +- `lib/data/models/briefing_conversation.dart` — `id`, `briefingDate`, `title`, `messages` +- `lib/providers/briefing_provider.dart` — `briefingTodayProvider` (AsyncNotifier) + +--- + +## LibraryScreen + +**File:** `lib/screens/library/library_screen.dart` + +Unified browsing screen for notes, tasks, and projects. + +### Layout + +``` +AppBar: + title: "Library" + actions: [🔍 search icon → expands inline search bar] + +Filter pills (scrollable horizontal row): + [All] [Notes] [Tasks] [Projects] + +Content: + Unified list sorted by updated_at desc + Each item: LibraryItemCard + +FAB: + [+] → bottom sheet: "New note" | "New task" +``` + +### Filter Pills +| Pill | Content | Extra controls | +|------|---------|----------------| +| All | Notes + tasks interleaved | — | +| Notes | Notes only | — | +| Tasks | Tasks only | Secondary status row: Todo · In Progress · Done · All | +| Projects | Project cards | — | + +### Item Cards (`lib/widgets/library_item_card.dart`) +- **Note:** title (Fraunces medium), 1-line body snippet, tag chips, relative timestamp +- **Task:** status checkbox (tappable → cycles `todo → in_progress → done` via `PATCH /api/tasks/:id/status`), title, due date, priority dot (high = red, medium = amber) +- **Project:** left colour strip matching project colour, title, active task count, milestone progress bar + +### Search +- Tapping 🔍 slides an `AnimatedContainer` search bar into the AppBar +- Searches title + body across notes and tasks via `GET /api/notes?q=` (respects active filter pill) +- Dismisses with Escape / back gesture + +### FAB +- Bottom sheet with two large tap targets: "New note" → `NoteEditScreen`, "New task" → `TaskEditScreen` + +### New Files +- `lib/screens/library/library_screen.dart` +- `lib/widgets/library_item_card.dart` + +--- + +## Quick Capture Queue + +The `_QuickCaptureBar` in `app.dart` is updated to support multiple in-flight captures queued sequentially. + +### Behaviour +- Input **never disables** while the worker is active (only disables on offline — existing behaviour) +- Submitting appends text to the work queue; the worker drains it one item at a time +- Each completion fires a snackbar (`"Note created: …"`) +- Queue depth > 0: prefix icon shows a badge `⋯ N` +- Worker active: a 2dp `LinearProgressIndicator` in indigo animates below the capture bar + +### Implementation +- New `lib/providers/capture_work_queue_provider.dart` — `CaptureWorkQueueNotifier` (in-memory `List` + async drain loop) +- Separate from the existing `captureQueueProvider` (which handles offline persistence); the two queues are distinct: + - **Work queue:** in-memory, drains sequentially while online + - **Offline queue:** persisted to SharedPreferences, drained on reconnect +- Network errors during work queue drain fall through to offline queue (existing logic) + +--- + +## Files Created / Modified Summary + +**New:** +- `lib/core/theme.dart` +- `lib/screens/briefing/briefing_screen.dart` +- `lib/screens/briefing/briefing_history_screen.dart` +- `lib/screens/library/library_screen.dart` +- `lib/widgets/briefing_digest_card.dart` +- `lib/widgets/chat_message_bubble.dart` +- `lib/widgets/library_item_card.dart` +- `lib/data/api/briefing_api.dart` +- `lib/data/models/briefing_conversation.dart` +- `lib/providers/briefing_provider.dart` +- `lib/providers/capture_work_queue_provider.dart` + +**Modified:** +- `lib/app.dart` — new 3-tab shell, new routes, updated `_QuickCaptureBar` +- `lib/main.dart` — import `theme.dart` +- `lib/screens/chat/chat_screen.dart` — extract bubble widget +- `pubspec.yaml` — add `google_fonts` + +**Deleted:** +- `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` + +--- + +## Out of Scope + +- iOS support — Android only, as today +- Workspace view — web-only feature +- Graph view — web-only feature +- Note editing from Library (tap → NoteDetailScreen → edit button, as today) +- Push notification handling in-app — handled by the OS notification tray From 3422caebfc18bcf29acdd74102697533ebc763b3 Mon Sep 17 00:00:00 2001 From: bvandeusen Date: Wed, 11 Mar 2026 22:52:19 -0400 Subject: [PATCH 3/7] =?UTF-8?q?docs:=20add=20Plan=203=20=E2=80=94=20Briefi?= =?UTF-8?q?ngScreen=20implementation=20plan?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2026-03-11-app-overhaul-p3-briefing.md | 1173 +++++++++++++++++ 1 file changed, 1173 insertions(+) create mode 100644 docs/superpowers/plans/2026-03-11-app-overhaul-p3-briefing.md diff --git a/docs/superpowers/plans/2026-03-11-app-overhaul-p3-briefing.md b/docs/superpowers/plans/2026-03-11-app-overhaul-p3-briefing.md new file mode 100644 index 0000000..6506f8f --- /dev/null +++ b/docs/superpowers/plans/2026-03-11-app-overhaul-p3-briefing.md @@ -0,0 +1,1173 @@ +# Fabled App Overhaul — Plan 3: Briefing Screen + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build the full BriefingScreen — today's briefing conversation with digest card, streaming reply bar, history navigation — backed by a new BriefingApi and BriefingNotifier. + +**Architecture:** A new `BriefingApi` calls the `/api/briefing/` endpoints for today's conversation and history; replies go through the existing `/api/chat/` message + SSE endpoints (briefing conversations are regular Conversations with `conversation_type = "briefing"`). `BriefingNotifier` (AsyncNotifier) owns the today conversation state with `sendReply` and `refresh` methods that mirror the existing `MessagesNotifier` pattern. A `ChatMessageBubble` widget is extracted from `ChatScreen` (applying the design-language styles from the spec) and shared. The BriefingScreen uses `ChatMessageBubble` for the conversation list, with a `BriefingDigestCard` pinned above it for the first assistant message. + +**Tech Stack:** Flutter/Dart, Riverpod, Dio (existing), custom `ColorScheme` from Plan 1. + +**Dependency:** Plans 1 and 2 must be applied first (theme + shell). This plan's tasks can otherwise be executed in order. + +--- + +## File Map + +**Create:** +- `lib/data/models/briefing_conversation.dart` — `BriefingConversation` model +- `lib/data/api/briefing_api.dart` — `BriefingApi` (getToday, getHistory, getMessages, triggerSlot) +- `lib/providers/briefing_provider.dart` — `BriefingNotifier`, `isBriefingStreamingProvider` +- `lib/widgets/chat_message_bubble.dart` — extracted + styled bubble widget (shared by Chat + Briefing) +- `lib/widgets/briefing_digest_card.dart` — expandable first-message card +- `lib/screens/briefing/briefing_history_screen.dart` — read-only past briefings list +- `lib/screens/briefing/briefing_screen.dart` — full implementation (replaces Plan 2 placeholder) + +**Modify:** +- `lib/providers/api_client_provider.dart` — add `briefingApiProvider` +- `lib/screens/chat/chat_screen.dart` — replace `_MessageBubble` with `ChatMessageBubble` + +--- + +## Chunk 1: Data Layer + +### Task 1: BriefingConversation model + BriefingApi + +**Files:** +- Create: `lib/data/models/briefing_conversation.dart` +- Create: `lib/data/api/briefing_api.dart` + +- [ ] **Step 1: Create the model** + +Create `lib/data/models/briefing_conversation.dart`: + +```dart +import 'message.dart'; + +class BriefingConversation { + final int id; + final String title; + final String? briefingDate; // YYYY-MM-DD or null + final List 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, + ); +} +``` + +- [ ] **Step 2: Create BriefingApi** + +Create `lib/data/api/briefing_api.dart`: + +```dart +import 'package:dio/dio.dart'; + +import '../models/briefing_conversation.dart'; +import '../models/message.dart'; +import 'api_client.dart'; + +class BriefingApi { + final Dio _dio; + const BriefingApi(this._dio); + + /// GET /api/briefing/conversations/today + /// Returns (or creates) today's briefing conversation with messages embedded. + Future 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); + } + } +} +``` + +- [ ] **Step 3: Register provider** + +In `lib/providers/api_client_provider.dart`, add after the `milestonesRepositoryProvider` block: + +```dart +// Add import at the top of the file: +import '../data/api/briefing_api.dart'; + +// Add provider: +final briefingApiProvider = Provider((ref) { + return BriefingApi(ref.watch(dioProvider)); +}); +``` + +- [ ] **Step 4: Verify the app still compiles** + +```bash +cd /home/bvandeusen/Nextcloud/Projects/fabled_app +flutter analyze --no-fatal-infos +``` + +Expected: no new errors. + +- [ ] **Step 5: Commit** + +```bash +git add lib/data/models/briefing_conversation.dart \ + lib/data/api/briefing_api.dart \ + lib/providers/api_client_provider.dart +git commit -m "feat: add BriefingApi and BriefingConversation model" +``` + +--- + +### Task 2: BriefingNotifier (state management) + +**Files:** +- Create: `lib/providers/briefing_provider.dart` + +The notifier owns today's briefing state. Replies use the existing `/api/chat/` message + SSE + poll pattern (same as `MessagesNotifier`), because briefing conversations are regular chat conversations under the hood. + +- [ ] **Step 1: Create briefing_provider.dart** + +```dart +import 'dart:async'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../data/models/briefing_conversation.dart'; +import '../data/models/message.dart'; +import 'api_client_provider.dart'; + +/// Drives the loading indicator in BriefingScreen's AppBar reply area. +final isBriefingStreamingProvider = StateProvider((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); + // Force a full reload from the server. + ref.invalidateSelf(); + await future; // wait for rebuild to complete + } + + /// Send a reply to today's briefing conversation. + /// + /// Mirrors MessagesNotifier.sendMessage(): + /// 1. Optimistic UI update + /// 2. POST message to chat endpoint + /// 3. SSE stream (best-effort) + /// 4. Poll until complete + Future sendReply(String content) async { + final conv = state.valueOrNull; + if (conv == null) return; + final convId = conv.id; + final chatApi = ref.read(chatApiProvider); + + final previous = conv.messages; + final userMsg = Message( + conversationId: convId, + role: MessageRole.user, + content: content, + ); + final placeholder = Message( + conversationId: convId, + role: MessageRole.assistant, + content: '', + status: 'generating', + ); + state = AsyncData(conv.copyWith(messages: [...previous, userMsg, placeholder])); + ref.read(isBriefingStreamingProvider.notifier).state = true; + + try { + await chatApi.sendMessage(convId, content); + } catch (e) { + state = AsyncData(conv.copyWith(messages: previous)); + ref.read(isBriefingStreamingProvider.notifier).state = false; + rethrow; + } + + // SSE stream (best-effort) + bool streamedContent = false; + try { + await for (final chunk in chatApi.streamGeneration(convId)) { + streamedContent = true; + final current = state.valueOrNull; + if (current == null) break; + final msgs = current.messages; + if (msgs.isEmpty) continue; + final updated = msgs.last.copyWith(content: msgs.last.content + chunk); + state = AsyncData(current.copyWith( + messages: [...msgs.sublist(0, msgs.length - 1), updated])); + } + } catch (_) { + // Fall through to polling. + } + + // Poll until complete (max 20 attempts, 2s apart) + try { + for (var attempt = 0; attempt < 20; attempt++) { + if (attempt > 0) await Future.delayed(const Duration(seconds: 2)); + final fresh = await ref.read(briefingApiProvider).getMessages(convId); + final done = fresh.any( + (m) => m.role == MessageRole.assistant && m.status != 'generating', + ); + final hasContent = fresh.any( + (m) => m.role == MessageRole.assistant && m.content.isNotEmpty, + ); + final current = state.valueOrNull; + if (current != null && (!streamedContent || done || hasContent)) { + state = AsyncData(current.copyWith(messages: fresh)); + } + if (done) break; + } + } catch (_) { + // Clear the generating placeholder so UI doesn't spin forever. + final current = state.valueOrNull; + if (current != null) { + final msgs = current.messages; + if (msgs.isNotEmpty && msgs.last.status == 'generating') { + state = AsyncData(current.copyWith(messages: [ + ...msgs.sublist(0, msgs.length - 1), + msgs.last.copyWith(status: 'complete'), + ])); + } + } + } finally { + ref.read(isBriefingStreamingProvider.notifier).state = false; + } + } +} +``` + +Note: `chatApiProvider` is already registered in `api_client_provider.dart`. + +- [ ] **Step 2: Verify compilation** + +```bash +flutter analyze --no-fatal-infos +``` + +Expected: no errors. + +- [ ] **Step 3: Commit** + +```bash +git add lib/providers/briefing_provider.dart +git commit -m "feat: add BriefingNotifier with sendReply and refresh" +``` + +--- + +## Chunk 2: Widgets + +### Task 3: ChatMessageBubble (extracted, shared widget) + +**Files:** +- Create: `lib/widgets/chat_message_bubble.dart` +- Modify: `lib/screens/chat/chat_screen.dart` + +Extract `_MessageBubble` from ChatScreen into a shared widget and apply the "Illuminated Transcript" design language from the spec: +- **User bubbles:** transparent background, thin primary-colour border, muted text (ghost style) +- **Assistant bubbles:** surface elevated, 2dp left accent border (indigo) + +- [ ] **Step 1: Create chat_message_bubble.dart** + +```dart +import 'dart:math' show min; + +import 'package:flutter/material.dart'; +import 'package:flutter_markdown_plus/flutter_markdown_plus.dart'; + +import '../data/models/message.dart'; + +class ChatMessageBubble extends StatelessWidget { + final Message message; + const ChatMessageBubble({super.key, required this.message}); + + @override + Widget build(BuildContext context) { + final isUser = message.role == MessageRole.user; + final scheme = Theme.of(context).colorScheme; + final isGenerating = message.status == 'generating'; + + return Align( + alignment: isUser ? Alignment.centerRight : Alignment.centerLeft, + child: Container( + constraints: BoxConstraints( + maxWidth: min(MediaQuery.of(context).size.width * 0.82, 480), + ), + margin: const EdgeInsets.symmetric(vertical: 4, horizontal: 4), + decoration: isUser + ? BoxDecoration( + // Ghost style: transparent bg, thin border + color: Colors.transparent, + border: Border.all( + color: scheme.primary.withOpacity(0.35), + width: 1, + ), + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(16), + topRight: Radius.circular(16), + bottomLeft: Radius.circular(16), + bottomRight: Radius.circular(4), + ), + ) + : BoxDecoration( + // Assistant: elevated surface + left accent border + color: scheme.surfaceContainerHighest, + border: Border( + left: BorderSide(color: scheme.primary, width: 2), + ), + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(4), + topRight: Radius.circular(16), + bottomLeft: Radius.circular(4), + bottomRight: Radius.circular(16), + ), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: isGenerating && message.content.isEmpty + ? SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: scheme.onSurfaceVariant, + ), + ) + : MarkdownBody( + data: message.content.isEmpty ? '…' : message.content, + styleSheet: MarkdownStyleSheet( + p: TextStyle( + color: isUser + ? scheme.onSurface.withOpacity(0.75) + : scheme.onSurface, + fontSize: 14, + ), + ), + ), + ), + ), + ); + } +} +``` + +- [ ] **Step 2: Replace `_MessageBubble` in chat_screen.dart** + +In `lib/screens/chat/chat_screen.dart`: + +Add import near the top (after existing imports): +```dart +import '../../widgets/chat_message_bubble.dart'; +``` + +In the `itemBuilder` within the ListView, replace: +```dart +_MessageBubble(message: messages[i]), +``` +with: +```dart +ChatMessageBubble(message: messages[i]), +``` + +Then delete the entire `_MessageBubble` class at the bottom of the file (lines starting with `class _MessageBubble` through to its closing `}`). + +- [ ] **Step 3: Verify compilation** + +```bash +flutter analyze --no-fatal-infos +``` + +Expected: no errors. + +- [ ] **Step 4: Commit** + +```bash +git add lib/widgets/chat_message_bubble.dart lib/screens/chat/chat_screen.dart +git commit -m "feat: extract ChatMessageBubble widget, apply design language" +``` + +--- + +### Task 4: BriefingDigestCard widget + +**Files:** +- Create: `lib/widgets/briefing_digest_card.dart` + +The DigestCard shows the **first** `assistant` message from the briefing conversation. It is truncated to 5 lines by default; tapping "Show more" expands with `AnimatedSize`. If no assistant message exists yet, a placeholder + "Generate now" button appears. + +- [ ] **Step 1: Create briefing_digest_card.dart** + +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_markdown_plus/flutter_markdown_plus.dart'; + +import '../data/models/message.dart'; + +class BriefingDigestCard extends StatefulWidget { + /// The first assistant message from today's briefing, or null if none yet. + final Message? message; + + /// Called when the user taps "Generate now". + final VoidCallback? onGenerateNow; + + const BriefingDigestCard({ + super.key, + required this.message, + this.onGenerateNow, + }); + + @override + State 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.withOpacity(0.5), + width: 1, + ), + ), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header row + Row( + children: [ + Icon(Icons.wb_sunny_outlined, + size: 18, color: scheme.primary), + const SizedBox(width: 8), + Text( + _todayLabel(), + style: textTheme.labelMedium?.copyWith( + color: scheme.onSurfaceVariant, + ), + ), + ], + ), + const SizedBox(height: 10), + + // Body + if (widget.message == null) ...[ + Text( + 'No briefing yet today.', + style: textTheme.bodyMedium?.copyWith( + color: scheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 12), + if (widget.onGenerateNow != null) + FilledButton.tonal( + onPressed: widget.onGenerateNow, + child: const Text('Generate now'), + ), + ] else ...[ + AnimatedSize( + duration: const Duration(milliseconds: 250), + curve: Curves.easeInOut, + alignment: Alignment.topCenter, + child: _expanded + ? MarkdownBody(data: widget.message!.content) + : _TruncatedMarkdown( + data: widget.message!.content, + maxLines: 5, + ), + ), + const SizedBox(height: 8), + GestureDetector( + onTap: () => setState(() => _expanded = !_expanded), + child: Text( + _expanded ? 'Show less ↑' : 'Show more ↓', + style: TextStyle( + color: scheme.primary, + fontSize: 13, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ], + ), + ), + ); + } + + String _todayLabel() { + final now = DateTime.now(); + const days = [ + 'Monday', 'Tuesday', 'Wednesday', 'Thursday', + 'Friday', 'Saturday', 'Sunday' + ]; + const months = [ + 'January', 'February', 'March', 'April', 'May', 'June', + 'July', 'August', 'September', 'October', 'November', 'December' + ]; + return '${days[now.weekday - 1]}, ${months[now.month - 1]} ${now.day}'; + } +} + +/// Renders Markdown truncated to [maxLines] visible lines. +class _TruncatedMarkdown extends StatelessWidget { + final String data; + final int maxLines; + const _TruncatedMarkdown({required this.data, required this.maxLines}); + + @override + Widget build(BuildContext context) { + // Clamp to maxLines by wrapping in a constrained box with clip. + return LayoutBuilder( + builder: (context, constraints) { + return ConstrainedBox( + constraints: BoxConstraints( + maxHeight: maxLines * 20.0, // approximate line height + ), + child: ClipRect( + child: MarkdownBody(data: data), + ), + ); + }, + ); + } +} +``` + +- [ ] **Step 2: Verify compilation** + +```bash +flutter analyze --no-fatal-infos +``` + +Expected: no errors. + +- [ ] **Step 3: Commit** + +```bash +git add lib/widgets/briefing_digest_card.dart +git commit -m "feat: add BriefingDigestCard widget with expand/collapse" +``` + +--- + +## Chunk 3: Screens + +### Task 5: BriefingHistoryScreen + +**Files:** +- Create: `lib/screens/briefing/briefing_history_screen.dart` + +A simple read-only screen listing past briefing dates. Tapping a row shows the messages for that briefing (push a message-list screen inline). No reply bar. + +- [ ] **Step 1: Create briefing_history_screen.dart** + +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../data/api/briefing_api.dart'; +import '../../data/models/briefing_conversation.dart'; +import '../../data/models/message.dart'; +import '../../providers/api_client_provider.dart'; +import '../../widgets/chat_message_bubble.dart'; + +class BriefingHistoryScreen extends ConsumerWidget { + const BriefingHistoryScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final historyAsync = ref.watch(_briefingHistoryProvider); + + return Scaffold( + appBar: AppBar(title: const Text('Past Briefings')), + body: historyAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (_, _) => + const Center(child: Text('Could not load briefing history.')), + data: (convs) { + if (convs.isEmpty) { + return const Center(child: Text('No past briefings.')); + } + return ListView.builder( + itemCount: convs.length, + itemBuilder: (context, i) { + final conv = convs[i]; + final label = conv.briefingDate ?? conv.title; + return ListTile( + leading: const Icon(Icons.wb_sunny_outlined), + title: Text(label), + trailing: const Icon(Icons.chevron_right), + onTap: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => _BriefingDetailScreen(conv: conv), + ), + ), + ); + }, + ); + }, + ), + ); + } +} + +/// Lazily loads and displays all messages for a past briefing. +class _BriefingDetailScreen extends ConsumerWidget { + final BriefingConversation conv; + const _BriefingDetailScreen({required this.conv}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final messagesAsync = + ref.watch(_briefingMessagesProvider(conv.id)); + + return Scaffold( + appBar: AppBar( + title: Text(conv.briefingDate ?? conv.title), + ), + body: messagesAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (_, _) => + const Center(child: Text('Could not load messages.')), + data: (messages) { + if (messages.isEmpty) { + return const Center(child: Text('No messages.')); + } + return ListView.builder( + padding: + const EdgeInsets.symmetric(horizontal: 8, vertical: 12), + itemCount: messages.length, + itemBuilder: (_, i) => + ChatMessageBubble(message: messages[i]), + ); + }, + ), + ); + } +} + +// ── Private providers (scoped to this file) ────────────────────────────────── + +final _briefingHistoryProvider = + FutureProvider>((ref) async { + return ref.watch(briefingApiProvider).getHistory(); +}); + +final _briefingMessagesProvider = + FutureProvider.family, int>((ref, convId) async { + return ref.watch(briefingApiProvider).getMessages(convId); +}); +``` + +- [ ] **Step 2: Verify compilation** + +```bash +flutter analyze --no-fatal-infos +``` + +Expected: no errors. + +- [ ] **Step 3: Commit** + +```bash +git add lib/screens/briefing/briefing_history_screen.dart +git commit -m "feat: add BriefingHistoryScreen (read-only past briefings)" +``` + +--- + +### Task 6: BriefingScreen (full implementation) + +**Files:** +- Modify (replace): `lib/screens/briefing/briefing_screen.dart` + +This replaces the placeholder created in Plan 2 with the full implementation. The layout from the spec: + +``` +AppBar: + title: "Briefing" (uses Fraunces via theme) + subtitle: today's date string + actions: [CircularProgressIndicator | ↻ refresh, ⋯ overflow menu] + +Body (Column): + BriefingDigestCard (first assistant message, expandable) + Divider + "Conversation" label + Expanded ListView (ChatMessageBubble for messages[1..]) + [streaming bubble at bottom if generating] + +Bottom pinned: + SafeArea → reply bar (TextField + GradientButton send) + LinearProgressIndicator (2dp, indigo) when streaming +``` + +The DigestCard shows `messages.firstWhere(role == assistant)`. The conversation list shows all messages **except** the first assistant message (which is in the card) — so index 0 if it's user, or skip the first assistant message. + +Simpler approach: show ALL messages in the list, and also show the first assistant message in the DigestCard. The DigestCard serves as a "header" summary, not a replacement for the message in the list. This avoids complicated index math and keeps the conversation complete. + +- [ ] **Step 1: Write briefing_screen.dart** + +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../core/exceptions.dart'; +import '../../data/models/message.dart'; +import '../../providers/briefing_provider.dart'; +import '../../widgets/briefing_digest_card.dart'; +import '../../widgets/chat_message_bubble.dart'; +import '../briefing/briefing_history_screen.dart'; + +class BriefingScreen extends ConsumerStatefulWidget { + const BriefingScreen({super.key}); + + @override + ConsumerState 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 (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Could not generate briefing.')), + ); + } + } finally { + if (mounted) setState(() => _refreshing = false); + } + } + + @override + Widget build(BuildContext context) { + final briefingAsync = ref.watch(briefingProvider); + final isStreaming = ref.watch(isBriefingStreamingProvider); + final scheme = Theme.of(context).colorScheme; + + // Scroll to bottom when messages change + ref.listen(briefingProvider, (_, _) => _scrollToBottom()); + + return Scaffold( + appBar: AppBar( + title: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Briefing'), + Text( + _todayLabel(), + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: scheme.onSurfaceVariant, + ), + ), + ], + ), + actions: [ + // Refresh button — shows spinner when refreshing + if (_refreshing) + const Padding( + padding: EdgeInsets.symmetric(horizontal: 12), + child: SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ) + else + IconButton( + icon: const Icon(Icons.refresh_outlined), + tooltip: 'Generate briefing', + onPressed: _refresh, + ), + PopupMenuButton( + onSelected: (value) { + if (value == 'history') { + Navigator.of(context).push(MaterialPageRoute( + builder: (_) => const BriefingHistoryScreen(), + )); + } + }, + itemBuilder: (_) => const [ + PopupMenuItem( + value: 'history', + child: Text('View past briefings'), + ), + ], + ), + ], + ), + body: briefingAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (_, _) => Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text('Could not load today\'s briefing.'), + const SizedBox(height: 12), + FilledButton.tonal( + onPressed: () => ref.invalidate(briefingProvider), + child: const Text('Retry'), + ), + ], + ), + ), + data: (conv) { + // First assistant message for the digest card (null if none yet) + final Message? firstAssistant = conv.messages + .where((m) => m.role == MessageRole.assistant) + .firstOrNull; + + return Column( + children: [ + // Digest card header + BriefingDigestCard( + message: firstAssistant, + onGenerateNow: _refresh, + ), + + // Divider + "Conversation" label + if (conv.messages.isNotEmpty) ...[ + const SizedBox(height: 4), + Row(children: [ + const Expanded(child: Divider()), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 10), + child: Text( + 'Conversation', + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: scheme.onSurfaceVariant, + ), + ), + ), + const Expanded(child: Divider()), + ]), + ], + + // Message list + Expanded( + child: ListView.builder( + controller: _scrollController, + padding: const EdgeInsets.symmetric( + horizontal: 8, vertical: 8), + itemCount: conv.messages.length, + itemBuilder: (_, i) => + ChatMessageBubble(message: conv.messages[i]), + ), + ), + + // Progress bar while streaming + if (isStreaming) + LinearProgressIndicator( + minHeight: 2, + color: scheme.primary, + ), + + // Reply bar + const Divider(height: 1), + SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(8, 6, 8, 6), + child: Row( + children: [ + Expanded( + child: TextField( + controller: _controller, + decoration: const InputDecoration( + hintText: 'Reply to your briefing…', + border: OutlineInputBorder(), + isDense: true, + contentPadding: EdgeInsets.symmetric( + horizontal: 12, vertical: 10), + ), + minLines: 1, + maxLines: 4, + textInputAction: TextInputAction.newline, + enabled: !isStreaming, + ), + ), + const SizedBox(width: 8), + // GradientButton send — from Plan 1 theme + _GradientSendButton( + onPressed: isStreaming ? null : _sendReply, + isStreaming: isStreaming, + ), + ], + ), + ), + ), + ], + ); + }, + ), + ); + } + + String _todayLabel() { + final now = DateTime.now(); + const days = [ + 'Monday', 'Tuesday', 'Wednesday', 'Thursday', + 'Friday', 'Saturday', 'Sunday' + ]; + const months = [ + 'January', 'February', 'March', 'April', 'May', 'June', + 'July', 'August', 'September', 'October', 'November', 'December' + ]; + return '${days[now.weekday - 1]}, ${months[now.month - 1]} ${now.day}'; + } +} + +/// Send button with the indigo gradient from the design spec. +/// Uses a simple DecoratedBox + InkWell to keep it dependency-free. +class _GradientSendButton extends StatelessWidget { + final VoidCallback? onPressed; + final bool isStreaming; + + const _GradientSendButton({ + required this.onPressed, + required this.isStreaming, + }); + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + final disabled = onPressed == null; + + return DecoratedBox( + decoration: BoxDecoration( + gradient: disabled + ? null + : const LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [Color(0xFF6366F1), Color(0xFF4F46E5)], + ), + color: disabled ? scheme.onSurface.withOpacity(0.12) : null, + borderRadius: BorderRadius.circular(10), + ), + child: IconButton( + icon: isStreaming + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : Icon( + Icons.send, + color: disabled ? scheme.onSurface.withOpacity(0.38) : Colors.white, + ), + onPressed: onPressed, + ), + ); + } +} +``` + +- [ ] **Step 2: Verify compilation** + +```bash +flutter analyze --no-fatal-infos +``` + +Expected: no errors. If `firstOrNull` is not available on the filtered Iterable, wrap with `.toList()` first: `conv.messages.where(...).toList().firstOrNull`. + +- [ ] **Step 3: Verify on device / emulator (manual)** + +``` +- App opens → Briefing tab shown by default +- If server has today's briefing: DigestCard shows first assistant message +- "Show more ↓" expands card; "Show less ↑" collapses +- If no briefing: placeholder + "Generate now" appears +- Tapping ↻ calls trigger slot, shows progress, reloads +- Type reply → tap send → user bubble appears, generating spinner, then assistant reply +- ⋯ menu → "View past briefings" → pushes BriefingHistoryScreen +- BriefingHistory lists past dates → tap one → read-only message list +``` + +- [ ] **Step 4: Commit** + +```bash +git add lib/screens/briefing/briefing_screen.dart +git commit -m "feat: implement full BriefingScreen (digest card, reply bar, history nav)" +``` + +--- + +## Chunk 4: Final Wiring + +### Task 7: Final compile check + push + +- [ ] **Step 1: Full analyze pass** + +```bash +cd /home/bvandeusen/Nextcloud/Projects/fabled_app +flutter analyze --no-fatal-infos 2>&1 | tail -30 +``` + +Expected: "No issues found!" or only pre-existing warnings unrelated to this plan. + +- [ ] **Step 2: Verify the full file list** + +```bash +ls lib/data/models/briefing_conversation.dart \ + lib/data/api/briefing_api.dart \ + lib/providers/briefing_provider.dart \ + lib/widgets/chat_message_bubble.dart \ + lib/widgets/briefing_digest_card.dart \ + lib/screens/briefing/briefing_screen.dart \ + lib/screens/briefing/briefing_history_screen.dart +``` + +Expected: all 7 files present. + +- [ ] **Step 3: Commit summary tag** + +```bash +git tag p3-complete +git log --oneline -8 +``` + +Expected: 6 commits from this plan visible in the log. + +--- + +## Verification Checklist (all three plans applied) + +1. `flutter analyze` — clean build +2. App launches → opens on **Briefing** tab (not Notes or Chat) +3. Theme matches: dark = `#111113` background, `#6366F1` primary; Fraunces headings +4. Quick Capture bar: type, submit, immediately type again — second item queues while first is in-flight; snackbar fires per completion +5. Library tab: All / Notes / Tasks / Projects filter pills; search icon slides in search bar +6. Chat tab: tapping a conversation opens ChatScreen +7. Briefing screen: digest card, reply sends, history navigates +8. Old list screens gone: `/notes-list`, `/tasks-list`, `/projects-list` routes removed From 6232c7c99a848017feb749cacc0e4bffa980ba7a Mon Sep 17 00:00:00 2001 From: bvandeusen Date: Wed, 11 Mar 2026 23:17:38 -0400 Subject: [PATCH 4/7] =?UTF-8?q?feat:=20app=20overhaul=20=E2=80=94=20Briefi?= =?UTF-8?q?ng-first=20navigation,=20Library,=20capture=20queue?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plans 1-3 implemented: Plan 1 — Foundation - Add google_fonts ^6.2.1 - lib/core/theme.dart: slate-indigo ColorScheme (dark/light), Fraunces headings, GradientButton widget - lib/providers/capture_work_queue_provider.dart: in-memory sequential work queue; CaptureWorkQueueNotifier drains one item at a time; captureResultProvider feeds snackbars to UI - lib/app.dart: wire fabledDarkTheme/fabledLightTheme; replace blocking _QuickCaptureBar with queue-based implementation (progress bar, badge) Plan 2 — Navigation - 3-tab shell: Briefing · Library · Chat (was Notes · Tasks · Projects · Chat) - lib/screens/library/library_screen.dart: unified notes+tasks+projects list with filter pills, status sub-filter for tasks, live search, FAB - lib/widgets/library_item_card.dart: NoteLibraryCard, TaskLibraryCard (status cycle), ProjectLibraryCard - lib/screens/chat/conversations_tab_screen.dart: focused replacement for ConversationsListScreen - Delete 5 dead screens: notes_list, tasks_list, project_list, conversations_list, quick_capture Plan 3 — Briefing - lib/data/models/briefing_conversation.dart - lib/data/api/briefing_api.dart: getToday, getHistory, getMessages, triggerSlot - lib/providers/briefing_provider.dart: BriefingNotifier with sendReply (optimistic + SSE + poll, same pattern as MessagesNotifier) and refresh - lib/widgets/chat_message_bubble.dart: extracted + redesigned shared bubble (ghost user bubbles, left-accent assistant bubbles) - lib/widgets/briefing_digest_card.dart: expandable first-message card - lib/screens/briefing/briefing_screen.dart: digest card, conversation list, streaming reply bar, refresh button, history overflow menu - lib/screens/briefing/briefing_history_screen.dart: read-only past dates Co-Authored-By: Claude Sonnet 4.6 --- .../2026-03-11-app-overhaul-p1-foundation.md | 720 +++++++++++ .../2026-03-11-app-overhaul-p2-navigation.md | 1125 +++++++++++++++++ lib/app.dart | 282 ++--- lib/core/constants.dart | 2 + lib/core/theme.dart | 246 ++++ lib/data/api/briefing_api.dart | 62 + lib/data/models/briefing_conversation.dart | 35 + lib/providers/api_client_provider.dart | 5 + lib/providers/briefing_provider.dart | 113 ++ .../capture_work_queue_provider.dart | 98 ++ .../briefing/briefing_history_screen.dart | 88 ++ lib/screens/briefing/briefing_screen.dart | 299 +++++ lib/screens/chat/chat_screen.dart | 46 +- .../chat/conversations_list_screen.dart | 185 --- .../chat/conversations_tab_screen.dart | 126 ++ lib/screens/library/library_screen.dart | 294 +++++ lib/screens/notes/notes_list_screen.dart | 216 ---- lib/screens/projects/project_list_screen.dart | 631 --------- .../quick_capture/quick_capture_screen.dart | 151 --- lib/screens/tasks/tasks_list_screen.dart | 199 --- lib/widgets/briefing_digest_card.dart | 133 ++ lib/widgets/chat_message_bubble.dart | 79 ++ lib/widgets/library_item_card.dart | 271 ++++ pubspec.lock | 16 +- pubspec.yaml | 1 + 25 files changed, 3830 insertions(+), 1593 deletions(-) create mode 100644 docs/superpowers/plans/2026-03-11-app-overhaul-p1-foundation.md create mode 100644 docs/superpowers/plans/2026-03-11-app-overhaul-p2-navigation.md create mode 100644 lib/core/theme.dart create mode 100644 lib/data/api/briefing_api.dart create mode 100644 lib/data/models/briefing_conversation.dart create mode 100644 lib/providers/briefing_provider.dart create mode 100644 lib/providers/capture_work_queue_provider.dart create mode 100644 lib/screens/briefing/briefing_history_screen.dart create mode 100644 lib/screens/briefing/briefing_screen.dart delete mode 100644 lib/screens/chat/conversations_list_screen.dart create mode 100644 lib/screens/chat/conversations_tab_screen.dart create mode 100644 lib/screens/library/library_screen.dart delete mode 100644 lib/screens/notes/notes_list_screen.dart delete mode 100644 lib/screens/projects/project_list_screen.dart delete mode 100644 lib/screens/quick_capture/quick_capture_screen.dart delete mode 100644 lib/screens/tasks/tasks_list_screen.dart create mode 100644 lib/widgets/briefing_digest_card.dart create mode 100644 lib/widgets/chat_message_bubble.dart create mode 100644 lib/widgets/library_item_card.dart 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: From 8a31034621f491b5ae9672c39d8f79c7e2a20d9f Mon Sep 17 00:00:00 2001 From: bvandeusen Date: Wed, 11 Mar 2026 23:19:36 -0400 Subject: [PATCH 5/7] ci: switch runner label to py3.12-node22 to match act runner config --- .forgejo/workflows/build.yml | 2 +- .forgejo/workflows/ci.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.forgejo/workflows/build.yml b/.forgejo/workflows/build.yml index e55c8d4..628c80d 100644 --- a/.forgejo/workflows/build.yml +++ b/.forgejo/workflows/build.yml @@ -24,7 +24,7 @@ on: jobs: build: name: Build release APK - runs-on: ubuntu-latest + runs-on: py3.12-node22 container: image: ghcr.io/cirruslabs/flutter:stable steps: diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index c7bb658..be2189c 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -21,7 +21,7 @@ on: jobs: analyze: name: Analyze & test - runs-on: ubuntu-latest + runs-on: py3.12-node22 container: image: ghcr.io/cirruslabs/flutter:stable steps: From 4b6fca39a8069e7ff6bc136b4dd0b706cbcc22df Mon Sep 17 00:00:00 2001 From: bvandeusen Date: Wed, 11 Mar 2026 23:24:48 -0400 Subject: [PATCH 6/7] ci: merge build into ci workflow with needs gate; update README --- .forgejo/workflows/build.yml | 85 ----------------------------------- .forgejo/workflows/ci.yml | 87 +++++++++++++++++++++++++++++++++++- README.md | 75 ++++++++++++++++++++++--------- 3 files changed, 141 insertions(+), 106 deletions(-) delete mode 100644 .forgejo/workflows/build.yml diff --git a/.forgejo/workflows/build.yml b/.forgejo/workflows/build.yml deleted file mode 100644 index 628c80d..0000000 --- a/.forgejo/workflows/build.yml +++ /dev/null @@ -1,85 +0,0 @@ -# Branch push (dev): builds APK, uploads as artifact named fabledapp-dev- -# Branch push (main): builds APK, uploads as artifact named fabledapp- -# Tag push (v26.03.09): builds APK, uploads artifact + creates Forgejo Release -# -# To cut a release: -# git tag v26.03.09 && git push origin v26.03.09 -# -# Required secrets (repo → Settings → Secrets → Actions): -# RELEASE_TOKEN — Forgejo PAT with write:repository scope -name: Build APK - -on: - push: - branches: [main, dev] - tags: ["v*"] - paths: - - "lib/**" - - "pubspec.yaml" - - "pubspec.lock" - - "android/**" - - "assets/**" - - ".forgejo/workflows/build.yml" - -jobs: - build: - name: Build release APK - runs-on: py3.12-node22 - container: - image: ghcr.io/cirruslabs/flutter:stable - steps: - - uses: actions/checkout@v6 - - - name: Install dependencies - run: flutter pub get - - - name: Build release APK - run: flutter build apk --release - - - name: Set artifact name - id: artifact - run: | - if [[ "${{ github.ref }}" == "refs/heads/dev" ]]; then - echo "name=fabledapp-dev-${{ github.sha }}" >> $GITHUB_OUTPUT - else - echo "name=fabledapp-${{ github.sha }}" >> $GITHUB_OUTPUT - fi - - - name: Upload artifact - uses: actions/upload-artifact@v6 - with: - name: ${{ steps.artifact.outputs.name }} - path: build/app/outputs/flutter-apk/app-release.apk - retention-days: 30 - - - name: Create Forgejo release - if: startsWith(github.ref, 'refs/tags/') - env: - RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }} - TAG: ${{ github.ref_name }} - run: | - echo "Creating release $TAG..." - - RESPONSE=$(curl -s -X POST \ - "https://git.fabledsword.com/api/v1/repos/bvandeusen/FabledApp/releases" \ - -H "Authorization: token $RELEASE_TOKEN" \ - -H "Content-Type: application/json" \ - -d "{\"tag_name\": \"$TAG\", \"name\": \"$TAG\", \"body\": \"\"}") - - RELEASE_ID=$(echo "$RESPONSE" | grep -oE '"id":[[:space:]]*[0-9]+' | head -1 | grep -oE '[0-9]+') - - if [ -z "$RELEASE_ID" ]; then - echo "Failed to create release. API response:" - echo "$RESPONSE" - exit 1 - fi - - echo "Release created with ID $RELEASE_ID, uploading APK..." - - curl -s -X POST \ - "https://git.fabledsword.com/api/v1/repos/bvandeusen/FabledApp/releases/$RELEASE_ID/assets" \ - -H "Authorization: token $RELEASE_TOKEN" \ - -F "attachment=@build/app/outputs/flutter-apk/app-release.apk" - - echo "Done — release $TAG is live at:" - echo "https://git.fabledsword.com/bvandeusen/FabledApp/releases/tag/$TAG" diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index be2189c..0679020 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -1,13 +1,29 @@ -name: CI +# CI runs first; build only proceeds if analyze and test pass. +# +# Push to dev: analyze + test → build APK → upload artifact (fabledapp-dev-) +# Push to main: analyze + test only (no build — wait for release tag) +# Tag v26.03.11: analyze + test → build APK → upload artifact + create Forgejo Release +# Pull request: analyze + test only +# +# To cut a release: +# git tag v26.03.11 && git push origin v26.03.11 +# +# Required secrets (repo → Settings → Secrets → Actions): +# RELEASE_TOKEN — Forgejo PAT with write:repository scope +name: CI & Build on: push: + branches: [main, dev] + tags: ["v*"] paths: - "lib/**" - "test/**" - "pubspec.yaml" - "pubspec.lock" - "analysis_options.yaml" + - "android/**" + - "assets/**" - ".forgejo/workflows/ci.yml" pull_request: paths: @@ -35,3 +51,72 @@ jobs: - name: Test run: flutter test + + build: + name: Build release APK + needs: [analyze] + # Build on dev branch pushes and version tag pushes only. + # main branch pushes run CI for safety but do not build — + # the release tag (v*) is the sole trigger for a production APK. + if: | + github.event_name == 'push' && + (github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/')) + runs-on: py3.12-node22 + container: + image: ghcr.io/cirruslabs/flutter:stable + steps: + - uses: actions/checkout@v6 + + - name: Install dependencies + run: flutter pub get + + - name: Build release APK + run: flutter build apk --release + + - name: Set artifact name + id: artifact + run: | + if [[ "${{ github.ref }}" == "refs/heads/dev" ]]; then + echo "name=fabledapp-dev-${{ github.sha }}" >> $GITHUB_OUTPUT + else + echo "name=fabledapp-${{ github.sha }}" >> $GITHUB_OUTPUT + fi + + - name: Upload artifact + uses: actions/upload-artifact@v6 + with: + name: ${{ steps.artifact.outputs.name }} + path: build/app/outputs/flutter-apk/app-release.apk + retention-days: 30 + + - name: Create Forgejo release + if: startsWith(github.ref, 'refs/tags/') + env: + RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }} + TAG: ${{ github.ref_name }} + run: | + echo "Creating release $TAG..." + + RESPONSE=$(curl -s -X POST \ + "https://git.fabledsword.com/api/v1/repos/bvandeusen/FabledApp/releases" \ + -H "Authorization: token $RELEASE_TOKEN" \ + -H "Content-Type: application/json" \ + -d "{\"tag_name\": \"$TAG\", \"name\": \"$TAG\", \"body\": \"\"}") + + RELEASE_ID=$(echo "$RESPONSE" | grep -oE '"id":[[:space:]]*[0-9]+' | head -1 | grep -oE '[0-9]+') + + if [ -z "$RELEASE_ID" ]; then + echo "Failed to create release. API response:" + echo "$RESPONSE" + exit 1 + fi + + echo "Release created with ID $RELEASE_ID, uploading APK..." + + curl -s -X POST \ + "https://git.fabledsword.com/api/v1/repos/bvandeusen/FabledApp/releases/$RELEASE_ID/assets" \ + -H "Authorization: token $RELEASE_TOKEN" \ + -F "attachment=@build/app/outputs/flutter-apk/app-release.apk" + + echo "Done — release $TAG is live at:" + echo "https://git.fabledsword.com/bvandeusen/FabledApp/releases/tag/$TAG" diff --git a/README.md b/README.md index 1d7d0d9..ba254cf 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,21 @@ # Fabled — Android App -Native Android client for [FabledAssistant](https://github.com/yourusername/fabledassistant), a self-hosted AI productivity assistant. +Native Android client for FabledAssistant, a self-hosted AI second-brain and productivity assistant. ## Features -- **Notes** — create, edit, and browse markdown notes -- **Tasks** — manage tasks with status (To Do / In Progress / Done) and priority -- **Chat** — streaming AI conversations with real-time SSE response display -- **Quick Capture** — FAB shortcut to create a note or task from anywhere -- **OAuth / SSO** — authenticates via your server's configured OIDC provider; local username/password login also supported if enabled on the server -- **Session persistence** — stays logged in across app restarts via a persistent cookie jar -- **Home screen widget** — tap to open the chat screen directly from the Android launcher +- **Daily Briefing** — the primary screen; opens on launch. Shows your AI-compiled morning digest (tasks, calendar, weather, RSS) with a full conversation you can reply to inline. Refresh manually or browse past briefings from the overflow menu. +- **Quick Capture** — always-visible input bar above all tabs. Type a note or task and submit; multiple captures queue sequentially so the input is never blocked. Falls back to offline persistence when the server is unreachable. +- **Library** — unified browsable list of notes, tasks, and projects with filter pills (All · Notes · Tasks · Projects). Tasks have a secondary status sub-filter and a tappable status icon to cycle todo → in progress → done without opening the editor. Inline search filters results live. +- **Chat** — streaming AI conversations with real-time SSE display. Tap + to start a new conversation or open an existing one. +- **Note & task editing** — full Markdown editor for notes, task editor with due date, priority, project, and milestone assignment. +- **OAuth / SSO** — authenticates via your server's OIDC provider; local username/password login also supported if enabled server-side. +- **Session persistence** — stays logged in across restarts via a persistent cookie jar. +- **Auto-update** — checks your Forgejo releases on launch and prompts to download and install new APKs in-app. ## Requirements -- A running [FabledAssistant](https://github.com/yourusername/fabledassistant) server (self-hosted) +- A running FabledAssistant server (self-hosted) - Android 5.0+ (API 21) ## Getting Started @@ -38,20 +39,33 @@ On first launch, enter your FabledAssistant server URL (e.g. `https://fabled.exa ``` lib/ -├── main.dart # Entry point; resolves async deps before runApp -├── app.dart # GoRouter + auth redirect guards + shell nav +├── main.dart # Entry point; resolves async deps before runApp +├── app.dart # GoRouter + auth redirect guards + 3-tab shell ├── core/ -│ ├── constants.dart # Route name constants -│ └── exceptions.dart # AppException hierarchy +│ ├── constants.dart # Route name constants +│ ├── exceptions.dart # AppException hierarchy +│ └── theme.dart # Custom slate-indigo ColorScheme + Fraunces typography ├── data/ -│ ├── api/ # Dio HTTP layer (one class per resource) -│ ├── models/ # Plain Dart models with fromJson/toJson -│ └── repositories/ # Thin wrappers over API classes -└── providers/ # Riverpod providers (state + dependency wiring) - screens/ # Flutter UI screens +│ ├── api/ # Dio HTTP layer (one class per resource) +│ ├── models/ # Plain Dart models with fromJson/toJson +│ └── repositories/ # Thin wrappers over API classes +├── providers/ # Riverpod providers (state + dependency wiring) +│ ├── briefing_provider.dart # Today's briefing — optimistic UI, SSE, polling +│ └── capture_work_queue_provider.dart # Sequential in-memory capture queue +├── screens/ +│ ├── briefing/ # BriefingScreen + BriefingHistoryScreen +│ ├── library/ # LibraryScreen (unified notes/tasks/projects) +│ ├── chat/ # ConversationsTabScreen + ChatScreen +│ ├── notes/ # NoteDetailScreen + NoteEditScreen +│ ├── tasks/ # TaskEditScreen +│ └── settings/ auth/ setup/ splash/ +└── widgets/ + ├── chat_message_bubble.dart # Shared bubble (used by Chat + Briefing) + ├── briefing_digest_card.dart # Expandable first-message card + └── library_item_card.dart # Note / task / project row widgets ``` -**Key packages:** `flutter_riverpod`, `go_router`, `dio` + `cookie_jar`, `flutter_inappwebview`, `flutter_markdown` +**Key packages:** `flutter_riverpod`, `go_router`, `dio` + `cookie_jar`, `google_fonts`, `flutter_markdown_plus` ## Building a Release APK @@ -59,4 +73,25 @@ lib/ flutter build apk --release ``` -The signed APK will be at `build/app/outputs/flutter-apk/app-release.apk`. +The APK will be at `build/app/outputs/flutter-apk/app-release.apk`. + +## CI / CD + +CI and build are defined in a single workflow (`.forgejo/workflows/ci.yml`) running on the shared `py3.12-node22` act runner with the `ghcr.io/cirruslabs/flutter:stable` container. + +| Trigger | Analyze + Test | Build APK | Release | +|---------|---------------|-----------|---------| +| PR | ✓ | — | — | +| Push `dev` | ✓ | ✓ (artifact `fabledapp-dev-`) | — | +| Push `main` | ✓ | — | — | +| Tag `v*` | ✓ | ✓ (artifact `fabledapp-`) | ✓ Forgejo Release + APK attached | + +The build job has `needs: [analyze]` — a failed analyze or test blocks the APK build. + +To cut a release: + +```bash +git tag v26.03.11 && git push origin v26.03.11 +``` + +Requires a `RELEASE_TOKEN` secret (Forgejo PAT with `write:repository` scope) set in repo Settings → Secrets → Actions. From fa1b65484c81ec02e0f8edbf7f2396a90ca766b1 Mon Sep 17 00:00:00 2001 From: bvandeusen Date: Wed, 11 Mar 2026 23:29:24 -0400 Subject: [PATCH 7/7] ci: attach APK to existing release when created via Forgejo UI --- .forgejo/workflows/ci.yml | 42 ++++++++++++++++++++++----------------- 1 file changed, 24 insertions(+), 18 deletions(-) diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index 0679020..50ead4e 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -89,34 +89,40 @@ jobs: path: build/app/outputs/flutter-apk/app-release.apk retention-days: 30 - - name: Create Forgejo release + - name: Publish Forgejo release if: startsWith(github.ref, 'refs/tags/') env: RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }} TAG: ${{ github.ref_name }} + API: https://git.fabledsword.com/api/v1/repos/bvandeusen/FabledApp run: | - echo "Creating release $TAG..." + # Look for an existing release (created via the UI or a prior run). + EXISTING=$(curl -s \ + "$API/releases/tags/$TAG" \ + -H "Authorization: token $RELEASE_TOKEN") - RESPONSE=$(curl -s -X POST \ - "https://git.fabledsword.com/api/v1/repos/bvandeusen/FabledApp/releases" \ - -H "Authorization: token $RELEASE_TOKEN" \ - -H "Content-Type: application/json" \ - -d "{\"tag_name\": \"$TAG\", \"name\": \"$TAG\", \"body\": \"\"}") + RELEASE_ID=$(echo "$EXISTING" | grep -oE '"id":[[:space:]]*[0-9]+' | head -1 | grep -oE '[0-9]+') - RELEASE_ID=$(echo "$RESPONSE" | grep -oE '"id":[[:space:]]*[0-9]+' | head -1 | grep -oE '[0-9]+') - - if [ -z "$RELEASE_ID" ]; then - echo "Failed to create release. API response:" - echo "$RESPONSE" - exit 1 + if [ -n "$RELEASE_ID" ]; then + echo "Found existing release $TAG (id $RELEASE_ID), attaching APK..." + else + echo "No existing release found, creating $TAG..." + RESPONSE=$(curl -s -X POST "$API/releases" \ + -H "Authorization: token $RELEASE_TOKEN" \ + -H "Content-Type: application/json" \ + -d "{\"tag_name\": \"$TAG\", \"name\": \"$TAG\", \"body\": \"\"}") + RELEASE_ID=$(echo "$RESPONSE" | grep -oE '"id":[[:space:]]*[0-9]+' | head -1 | grep -oE '[0-9]+') + if [ -z "$RELEASE_ID" ]; then + echo "Failed to create release. API response:" + echo "$RESPONSE" + exit 1 + fi + echo "Release created with id $RELEASE_ID." fi - echo "Release created with ID $RELEASE_ID, uploading APK..." - - curl -s -X POST \ - "https://git.fabledsword.com/api/v1/repos/bvandeusen/FabledApp/releases/$RELEASE_ID/assets" \ + curl -s -X POST "$API/releases/$RELEASE_ID/assets" \ -H "Authorization: token $RELEASE_TOKEN" \ -F "attachment=@build/app/outputs/flutter-apk/app-release.apk" - echo "Done — release $TAG is live at:" + echo "Done — $TAG is live at:" echo "https://git.fabledsword.com/bvandeusen/FabledApp/releases/tag/$TAG"