# Knowledge View Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Replace the Library tab with a typed Knowledge view, add a Projects tab, and wire a two-tier paginated feed (IDs + batch hydration) backed by `/api/knowledge`. **Architecture:** Four-tab shell (Briefing / Knowledge / Chat / Projects). Knowledge screen fetches up to 50 IDs per page then hydrates 12 items at a time via batch endpoint. Type tabs filter server-side. Projects screen lists all projects sorted by `updated_at desc` with a dedicated create/edit screen. All state managed with Riverpod StateNotifier / AsyncNotifier following existing app patterns. **Tech Stack:** Flutter 3, Riverpod 3, GoRouter 17, Dio 5. No new pubspec dependencies. --- ## Manifest Pre-Check (do this before Task 1) Open `android/app/src/main/AndroidManifest.xml` and confirm all three of the following are present. **If any are missing, add them before proceeding.** - `` — already present ✓ - `` — already present ✓ - `android:usesCleartextTraffic="true"` on the `` tag — already present ✓ No manifest changes required for this feature. --- ## File Map | Action | File | |---|---| | Modify | `lib/core/constants.dart` | | Modify | `lib/data/models/note.dart` | | Modify | `lib/data/api/notes_api.dart` | | Modify | `lib/data/repositories/notes_repository.dart` | | Modify | `lib/providers/notes_provider.dart` | | Modify | `lib/screens/notes/note_edit_screen.dart` | | Create | `lib/data/models/knowledge_item.dart` | | Create | `lib/data/api/knowledge_api.dart` | | Create | `lib/data/repositories/knowledge_repository.dart` | | Modify | `lib/providers/api_client_provider.dart` | | Create | `lib/providers/knowledge_provider.dart` | | Create | `lib/widgets/knowledge_item_card.dart` | | Create | `lib/screens/knowledge/knowledge_screen.dart` | | Modify | `lib/data/api/projects_api.dart` | | Modify | `lib/data/repositories/projects_repository.dart` | | Modify | `lib/providers/projects_provider.dart` | | Create | `lib/screens/projects/projects_screen.dart` | | Create | `lib/screens/projects/project_edit_screen.dart` | | Modify | `lib/screens/library/project_tasks_screen.dart` | | Modify | `lib/app.dart` | | Delete | `lib/screens/library/library_screen.dart` | | Delete | `lib/widgets/library_item_card.dart` | --- ## Task 1: Route Constants **Files:** - Modify: `lib/core/constants.dart` - [ ] **Step 1: Update constants** Replace the contents of `lib/core/constants.dart` with: ```dart abstract class Routes { static const splash = '/'; static const setup = '/setup'; static const login = '/login'; static const notes = '/notes'; static const noteDetail = '/notes/:id'; static const noteEdit = '/notes/:id/edit'; static const noteNew = '/notes/new'; static const tasks = '/tasks'; static const taskNew = '/tasks/new'; static const taskEdit = '/tasks/:id/edit'; static const knowledge = '/knowledge'; static const projects = '/projects'; static const projectEdit = '/projects/:id/edit'; static const conversations = '/chat'; static const chat = '/chat/:id'; static const quickCapture = '/quick-capture'; static const settings = '/settings'; static const briefing = '/briefing'; static const projectTasks = '/projects/:id/tasks'; } ``` Changes: `library` removed, `knowledge` added, `projectEdit` added. - [ ] **Step 2: Verify** ```bash cd /home/bvandeusen/Nextcloud/Projects/fabled_app flutter analyze lib/core/constants.dart ``` Expected: `No issues found!` - [ ] **Step 3: Commit** ```bash git add lib/core/constants.dart git commit -m "feat(knowledge): add knowledge + projectEdit route constants" ``` --- ## Task 2: Note Model — Add noteType **Files:** - Modify: `lib/data/models/note.dart` - Modify: `lib/data/api/notes_api.dart` - Modify: `lib/data/repositories/notes_repository.dart` - Modify: `lib/providers/notes_provider.dart` - Modify: `test/widget_test.dart` - [ ] **Step 1: Write a failing test** Replace `test/widget_test.dart` with: ```dart import 'package:flutter_test/flutter_test.dart'; void main() { group('Note.fromJson', () { test('parses noteType when present', () { final json = { 'id': 1, 'title': 'Alice', 'body': '', 'tags': [], 'note_type': 'person', 'created_at': '2024-01-01T00:00:00', 'updated_at': '2024-01-01T00:00:00', }; // Note class is not imported yet — this will fail to compile // until Task 2 Step 2 is complete. }); }); } ``` Run to confirm it fails: ```bash flutter test test/widget_test.dart ``` Expected: compilation error (Note not imported) — confirms test is wired. - [ ] **Step 2: Update `lib/data/models/note.dart`** ```dart class Note { final int id; final String title; final String body; final List tags; final String noteType; final int? projectId; final int? milestoneId; final DateTime createdAt; final DateTime updatedAt; const Note({ required this.id, required this.title, required this.body, required this.tags, this.noteType = 'note', this.projectId, this.milestoneId, required this.createdAt, required this.updatedAt, }); factory Note.fromJson(Map json) => Note( id: json['id'] as int, title: json['title'] as String? ?? '', body: json['body'] as String? ?? '', tags: (json['tags'] as List?) ?.map((e) => e as String) .toList() ?? [], noteType: json['note_type'] as String? ?? 'note', projectId: json['project_id'] as int?, milestoneId: json['milestone_id'] as int?, createdAt: DateTime.parse(json['created_at'] as String), updatedAt: DateTime.parse(json['updated_at'] as String), ); Map toJson() => { 'title': title, 'body': body, 'tags': tags, 'note_type': noteType, 'project_id': projectId, 'milestone_id': milestoneId, }; Note copyWith({ String? title, String? body, List? tags, String? noteType, Object? projectId = _undefined, Object? milestoneId = _undefined, }) => Note( id: id, title: title ?? this.title, body: body ?? this.body, tags: tags ?? this.tags, noteType: noteType ?? this.noteType, projectId: identical(projectId, _undefined) ? this.projectId : projectId as int?, milestoneId: identical(milestoneId, _undefined) ? this.milestoneId : milestoneId as int?, createdAt: createdAt, updatedAt: updatedAt, ); static const _undefined = Object(); } ``` - [ ] **Step 3: Write the real test and verify it passes** Replace `test/widget_test.dart`: ```dart import 'package:fabled_app/data/models/note.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { group('Note.fromJson', () { test('parses noteType when present', () { final json = { 'id': 1, 'title': 'Alice', 'body': '', 'tags': [], 'note_type': 'person', 'created_at': '2024-01-01T00:00:00', 'updated_at': '2024-01-01T00:00:00', }; final note = Note.fromJson(json); expect(note.noteType, equals('person')); }); test('defaults noteType to note when absent', () { final json = { 'id': 2, 'title': 'My note', 'body': '', 'tags': [], 'created_at': '2024-01-01T00:00:00', 'updated_at': '2024-01-01T00:00:00', }; final note = Note.fromJson(json); expect(note.noteType, equals('note')); }); }); } ``` ```bash flutter test test/widget_test.dart ``` Expected: `All tests passed!` - [ ] **Step 4: Update `lib/data/api/notes_api.dart`** Add `noteType` parameter to `create` and `update`: ```dart import 'package:dio/dio.dart'; import '../models/note.dart'; import 'api_client.dart'; class NotesApi { final Dio _dio; const NotesApi(this._dio); Future> getAll() async { try { final response = await _dio.get('/api/notes'); final data = response.data as Map; final list = data['notes'] as List; return list.map((e) => Note.fromJson(e as Map)).toList(); } on DioException catch (e) { throw dioToApp(e); } } Future getOne(int id) async { try { final response = await _dio.get('/api/notes/$id'); return Note.fromJson(response.data as Map); } on DioException catch (e) { throw dioToApp(e); } } Future create( String title, String body, { List tags = const [], int? projectId, String noteType = 'note', }) async { try { final response = await _dio.post('/api/notes', data: { 'title': title, 'body': body, 'tags': tags, 'note_type': noteType, if (projectId != null) 'project_id': projectId, }); return Note.fromJson(response.data as Map); } on DioException catch (e) { throw dioToApp(e); } } Future update( int id, String title, String body, { List tags = const [], int? projectId, bool clearProject = false, String noteType = 'note', }) async { try { final response = await _dio.put('/api/notes/$id', data: { 'title': title, 'body': body, 'tags': tags, 'note_type': noteType, if (clearProject) 'project_id': null else if (projectId != null) 'project_id': projectId, }); return Note.fromJson(response.data as Map); } on DioException catch (e) { throw dioToApp(e); } } Future delete(int id) async { try { await _dio.delete('/api/notes/$id'); } on DioException catch (e) { throw dioToApp(e); } } } ``` - [ ] **Step 5: Update `lib/data/repositories/notes_repository.dart`** ```dart import '../api/notes_api.dart'; import '../models/note.dart'; class NotesRepository { final NotesApi _api; const NotesRepository(this._api); Future> getAll() => _api.getAll(); Future getOne(int id) => _api.getOne(id); Future create( String title, String body, { List tags = const [], int? projectId, String noteType = 'note', }) => _api.create(title, body, tags: tags, projectId: projectId, noteType: noteType); Future update( int id, String title, String body, { List tags = const [], int? projectId, bool clearProject = false, String noteType = 'note', }) => _api.update(id, title, body, tags: tags, projectId: projectId, clearProject: clearProject, noteType: noteType); Future delete(int id) => _api.delete(id); } ``` - [ ] **Step 6: Update `lib/providers/notes_provider.dart`** ```dart import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../data/models/note.dart'; import 'api_client_provider.dart'; final notesProvider = AsyncNotifierProvider>(NotesNotifier.new); class NotesNotifier extends AsyncNotifier> { @override Future> build() async { return ref.watch(notesRepositoryProvider).getAll(); } Future create( String title, String body, { List tags = const [], int? projectId, String noteType = 'note', }) async { final note = await ref.read(notesRepositoryProvider).create( title, body, tags: tags, projectId: projectId, noteType: noteType, ); state = AsyncData([...state.value ?? [], note]); return note; } Future updateNote( int id, String title, String body, { List tags = const [], int? projectId, bool clearProject = false, String noteType = 'note', }) async { final updated = await ref.read(notesRepositoryProvider).update( id, title, body, tags: tags, projectId: projectId, clearProject: clearProject, noteType: noteType, ); state = AsyncData([ for (final n in state.value ?? []) if (n.id == id) updated else n, ]); return updated; } Future delete(int id) async { await ref.read(notesRepositoryProvider).delete(id); state = AsyncData([ for (final n in state.value ?? []) if (n.id != id) n, ]); } } final noteDetailProvider = FutureProvider.family((ref, id) async { return ref.watch(notesRepositoryProvider).getOne(id); }); ``` - [ ] **Step 7: Analyze** ```bash flutter analyze lib/data/models/note.dart lib/data/api/notes_api.dart lib/data/repositories/notes_repository.dart lib/providers/notes_provider.dart ``` Expected: `No issues found!` - [ ] **Step 8: Commit** ```bash git add lib/data/models/note.dart lib/data/api/notes_api.dart lib/data/repositories/notes_repository.dart lib/providers/notes_provider.dart test/widget_test.dart git commit -m "feat(knowledge): add noteType field to Note model and thread through API/repo/provider" ``` --- ## Task 3: KnowledgeItem Model **Files:** - Create: `lib/data/models/knowledge_item.dart` - [ ] **Step 1: Create the model** Create `lib/data/models/knowledge_item.dart`: ```dart /// Unified model for all knowledge types returned by /api/knowledge. /// noteType is one of: 'note' | 'person' | 'place' | 'list' | 'task' class KnowledgeItem { final int id; final String noteType; final String title; final String body; final List tags; final int? projectId; final int? milestoneId; final int? parentId; // Task-only fields — null for non-task types final String? status; final String? priority; final String? dueDate; final DateTime createdAt; final DateTime updatedAt; const KnowledgeItem({ required this.id, required this.noteType, required this.title, required this.body, required this.tags, this.projectId, this.milestoneId, this.parentId, this.status, this.priority, this.dueDate, required this.createdAt, required this.updatedAt, }); factory KnowledgeItem.fromJson(Map json) => KnowledgeItem( id: json['id'] as int, noteType: json['note_type'] as String? ?? 'note', title: json['title'] as String? ?? '', body: json['body'] as String? ?? '', tags: (json['tags'] as List?) ?.map((e) => e as String) .toList() ?? [], projectId: json['project_id'] as int?, milestoneId: json['milestone_id'] as int?, parentId: json['parent_id'] as int?, status: json['status'] as String?, priority: json['priority'] as String?, dueDate: json['due_date'] as String?, createdAt: DateTime.parse(json['created_at'] as String), updatedAt: DateTime.parse(json['updated_at'] as String), ); } ``` - [ ] **Step 2: Add a unit test to `test/widget_test.dart`** Append this group to the existing `main()` in `test/widget_test.dart`: ```dart // Add this import at the top of the file: // import 'package:fabled_app/data/models/knowledge_item.dart'; group('KnowledgeItem.fromJson', () { test('parses a task item with task fields', () { final json = { 'id': 10, 'note_type': 'task', 'title': 'Do the thing', 'body': '', 'tags': [], 'status': 'todo', 'priority': 'high', 'due_date': '2025-01-01', 'created_at': '2024-01-01T00:00:00', 'updated_at': '2024-01-01T00:00:00', }; final item = KnowledgeItem.fromJson(json); expect(item.noteType, equals('task')); expect(item.status, equals('todo')); expect(item.priority, equals('high')); }); test('defaults noteType to note when absent', () { final json = { 'id': 11, 'title': 'A note', 'body': '', 'tags': [], 'created_at': '2024-01-01T00:00:00', 'updated_at': '2024-01-01T00:00:00', }; final item = KnowledgeItem.fromJson(json); expect(item.noteType, equals('note')); expect(item.status, isNull); }); }); ``` Full updated `test/widget_test.dart`: ```dart import 'package:fabled_app/data/models/knowledge_item.dart'; import 'package:fabled_app/data/models/note.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { group('Note.fromJson', () { test('parses noteType when present', () { final json = { 'id': 1, 'title': 'Alice', 'body': '', 'tags': [], 'note_type': 'person', 'created_at': '2024-01-01T00:00:00', 'updated_at': '2024-01-01T00:00:00', }; final note = Note.fromJson(json); expect(note.noteType, equals('person')); }); test('defaults noteType to note when absent', () { final json = { 'id': 2, 'title': 'My note', 'body': '', 'tags': [], 'created_at': '2024-01-01T00:00:00', 'updated_at': '2024-01-01T00:00:00', }; final note = Note.fromJson(json); expect(note.noteType, equals('note')); }); }); group('KnowledgeItem.fromJson', () { test('parses a task item with task fields', () { final json = { 'id': 10, 'note_type': 'task', 'title': 'Do the thing', 'body': '', 'tags': [], 'status': 'todo', 'priority': 'high', 'due_date': '2025-01-01', 'created_at': '2024-01-01T00:00:00', 'updated_at': '2024-01-01T00:00:00', }; final item = KnowledgeItem.fromJson(json); expect(item.noteType, equals('task')); expect(item.status, equals('todo')); expect(item.priority, equals('high')); }); test('defaults noteType to note when absent', () { final json = { 'id': 11, 'title': 'A note', 'body': '', 'tags': [], 'created_at': '2024-01-01T00:00:00', 'updated_at': '2024-01-01T00:00:00', }; final item = KnowledgeItem.fromJson(json); expect(item.noteType, equals('note')); expect(item.status, isNull); }); }); } ``` - [ ] **Step 3: Run tests** ```bash flutter test test/widget_test.dart ``` Expected: `All tests passed!` - [ ] **Step 4: Commit** ```bash git add lib/data/models/knowledge_item.dart test/widget_test.dart git commit -m "feat(knowledge): add KnowledgeItem model with unit tests" ``` --- ## Task 4: KnowledgeApi + KnowledgeRepository + Provider Wiring **Files:** - Create: `lib/data/api/knowledge_api.dart` - Create: `lib/data/repositories/knowledge_repository.dart` - Modify: `lib/providers/api_client_provider.dart` - [ ] **Step 1: Create `lib/data/api/knowledge_api.dart`** ```dart import 'package:dio/dio.dart'; import '../models/knowledge_item.dart'; import 'api_client.dart'; class KnowledgeApi { final Dio _dio; const KnowledgeApi(this._dio); /// Fetch a page of IDs matching the given filters. /// Returns (ids, total). Future<(List, int)> fetchIds({ String? noteType, List tags = const [], String sort = 'modified', String? q, int limit = 50, int offset = 0, }) async { try { final params = { 'limit': limit, 'offset': offset, 'sort': sort, if (noteType != null) 'type': noteType, if (tags.isNotEmpty) 'tags': tags.join(','), if (q != null && q.isNotEmpty) 'q': q, }; final response = await _dio.get( '/api/knowledge/ids', queryParameters: params, ); final data = response.data as Map; final ids = (data['ids'] as List).map((e) => e as int).toList(); final total = data['total'] as int; return (ids, total); } on DioException catch (e) { throw dioToApp(e); } } /// Batch-hydrate up to 100 IDs into full KnowledgeItems. Future> fetchBatch(List ids) async { if (ids.isEmpty) return []; try { final response = await _dio.get( '/api/knowledge/batch', queryParameters: {'ids': ids.join(',')}, ); final data = response.data as Map; return (data['items'] as List) .map((e) => KnowledgeItem.fromJson(e as Map)) .toList(); } on DioException catch (e) { throw dioToApp(e); } } /// Per-type counts for tab labels. Future> fetchCounts({List tags = const []}) async { try { final response = await _dio.get( '/api/knowledge/counts', queryParameters: { if (tags.isNotEmpty) 'tags': tags.join(','), }, ); return (response.data as Map) .map((k, v) => MapEntry(k, (v as num).toInt())); } on DioException catch (e) { throw dioToApp(e); } } /// Tags available for the given type filter. Future> fetchTags({String? noteType}) async { try { final response = await _dio.get( '/api/knowledge/tags', queryParameters: { if (noteType != null) 'type': noteType, }, ); return (response.data['tags'] as List) .map((e) => e as String) .toList(); } on DioException catch (e) { throw dioToApp(e); } } } ``` - [ ] **Step 2: Create `lib/data/repositories/knowledge_repository.dart`** ```dart import '../api/knowledge_api.dart'; import '../models/knowledge_item.dart'; class KnowledgeRepository { final KnowledgeApi _api; const KnowledgeRepository(this._api); Future<(List, int)> fetchIds({ String? noteType, List tags = const [], String sort = 'modified', String? q, int limit = 50, int offset = 0, }) => _api.fetchIds( noteType: noteType, tags: tags, sort: sort, q: q, limit: limit, offset: offset, ); Future> fetchBatch(List ids) => _api.fetchBatch(ids); Future> fetchCounts({List tags = const []}) => _api.fetchCounts(tags: tags); Future> fetchTags({String? noteType}) => _api.fetchTags(noteType: noteType); } ``` - [ ] **Step 3: Add providers to `lib/providers/api_client_provider.dart`** Add these imports at the top (after the existing imports): ```dart import '../data/api/knowledge_api.dart'; import '../data/repositories/knowledge_repository.dart'; ``` Add these provider declarations at the bottom of the file (before the last closing brace if any, otherwise just append): ```dart final knowledgeApiProvider = Provider((ref) { return KnowledgeApi(ref.watch(dioProvider)); }); final knowledgeRepositoryProvider = Provider((ref) { return KnowledgeRepository(ref.watch(knowledgeApiProvider)); }); ``` - [ ] **Step 4: Analyze** ```bash flutter analyze lib/data/api/knowledge_api.dart lib/data/repositories/knowledge_repository.dart lib/providers/api_client_provider.dart ``` Expected: `No issues found!` - [ ] **Step 5: Commit** ```bash git add lib/data/api/knowledge_api.dart lib/data/repositories/knowledge_repository.dart lib/providers/api_client_provider.dart git commit -m "feat(knowledge): add KnowledgeApi, KnowledgeRepository, and provider wiring" ``` --- ## Task 5: KnowledgeNotifier **Files:** - Create: `lib/providers/knowledge_provider.dart` - [ ] **Step 1: Create `lib/providers/knowledge_provider.dart`** ```dart import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../data/models/knowledge_item.dart'; import 'api_client_provider.dart'; /// Immutable state for the Knowledge screen's two-tier paginated feed. class KnowledgeState { final List ids; final Map items; final int totalIds; final bool isLoadingIds; final bool isLoadingBatch; final bool hasMore; final String? noteType; // null = All final List activeTags; final String? searchQuery; final Map counts; final List availableTags; final String? error; const KnowledgeState({ this.ids = const [], this.items = const {}, this.totalIds = 0, this.isLoadingIds = false, this.isLoadingBatch = false, this.hasMore = false, this.noteType, this.activeTags = const [], this.searchQuery, this.counts = const {}, this.availableTags = const [], this.error, }); /// Items in server-defined ID order, only those already hydrated. List get orderedItems => ids.where(items.containsKey).map((id) => items[id]!).toList(); /// IDs that have been fetched but not yet hydrated. List get unhydratedIds => ids.where((id) => !items.containsKey(id)).toList(); KnowledgeState copyWith({ List? ids, Map? items, int? totalIds, bool? isLoadingIds, bool? isLoadingBatch, bool? hasMore, Object? noteType = _keep, List? activeTags, Object? searchQuery = _keep, Map? counts, List? availableTags, Object? error = _keep, }) => KnowledgeState( ids: ids ?? this.ids, items: items ?? this.items, totalIds: totalIds ?? this.totalIds, isLoadingIds: isLoadingIds ?? this.isLoadingIds, isLoadingBatch: isLoadingBatch ?? this.isLoadingBatch, hasMore: hasMore ?? this.hasMore, noteType: identical(noteType, _keep) ? this.noteType : noteType as String?, activeTags: activeTags ?? this.activeTags, searchQuery: identical(searchQuery, _keep) ? this.searchQuery : searchQuery as String?, counts: counts ?? this.counts, availableTags: availableTags ?? this.availableTags, error: identical(error, _keep) ? this.error : error as String?, ); static const _keep = Object(); } class KnowledgeNotifier extends StateNotifier { KnowledgeNotifier(this._ref) : super(const KnowledgeState()); final Ref _ref; KnowledgeRepository get _repo => _ref.read(knowledgeRepositoryProvider); // ── Filter setters — each resets and re-fetches ────────────────────────── Future setTypeFilter(String? noteType) async { state = KnowledgeState(noteType: noteType, activeTags: state.activeTags); await _fetchFromScratch(); } Future toggleTag(String tag) async { final tags = state.activeTags.contains(tag) ? state.activeTags.where((t) => t != tag).toList() : [...state.activeTags, tag]; state = KnowledgeState( noteType: state.noteType, activeTags: tags, searchQuery: state.searchQuery, ); await _fetchFromScratch(); } Future setSearch(String? q) async { final query = (q?.trim().isEmpty ?? true) ? null : q?.trim(); state = KnowledgeState( noteType: state.noteType, activeTags: state.activeTags, searchQuery: query, ); await _fetchFromScratch(); } Future refresh() async { state = KnowledgeState( noteType: state.noteType, activeTags: state.activeTags, searchQuery: state.searchQuery, ); await _fetchFromScratch(); } // ── Scroll-triggered loaders ───────────────────────────────────────────── /// Hydrate the next 12 un-hydrated IDs. Call when approaching scroll end. Future hydrateNext() async { if (state.isLoadingBatch) return; final toFetch = state.unhydratedIds.take(12).toList(); if (toFetch.isEmpty) { // All fetched IDs are hydrated — try loading more IDs. if (state.hasMore && !state.isLoadingIds) await _loadMoreIds(); return; } state = state.copyWith(isLoadingBatch: true); try { final fetched = await _repo.fetchBatch(toFetch); final updated = Map.from(state.items); for (final item in fetched) { updated[item.id] = item; } state = state.copyWith(items: updated, isLoadingBatch: false); } catch (_) { state = state.copyWith(isLoadingBatch: false); } } // ── Private helpers ────────────────────────────────────────────────────── Future _fetchFromScratch() async { state = state.copyWith(isLoadingIds: true, error: null); try { final (ids, total) = await _repo.fetchIds( noteType: state.noteType, tags: state.activeTags, q: state.searchQuery, limit: 50, offset: 0, ); state = state.copyWith( ids: ids, items: {}, totalIds: total, isLoadingIds: false, hasMore: ids.length < total, ); // Load counts and tags in parallel with the first batch hydration. await Future.wait([ hydrateNext(), _loadCounts(), _loadTags(), ]); } catch (e) { state = state.copyWith( isLoadingIds: false, error: e.toString(), ); } } Future _loadMoreIds() async { if (!state.hasMore || state.isLoadingIds) return; state = state.copyWith(isLoadingIds: true); try { final (newIds, total) = await _repo.fetchIds( noteType: state.noteType, tags: state.activeTags, q: state.searchQuery, limit: 50, offset: state.ids.length, ); final combined = [...state.ids, ...newIds]; state = state.copyWith( ids: combined, totalIds: total, isLoadingIds: false, hasMore: combined.length < total, ); } catch (_) { state = state.copyWith(isLoadingIds: false); } } Future _loadCounts() async { try { final counts = await _repo.fetchCounts(tags: state.activeTags); state = state.copyWith(counts: counts); } catch (_) {} } Future _loadTags() async { try { final tags = await _repo.fetchTags(noteType: state.noteType); state = state.copyWith(availableTags: tags); } catch (_) {} } } final knowledgeProvider = StateNotifierProvider( (ref) => KnowledgeNotifier(ref), ); ``` - [ ] **Step 2: Analyze** ```bash flutter analyze lib/providers/knowledge_provider.dart ``` Expected: `No issues found!` - [ ] **Step 3: Commit** ```bash git add lib/providers/knowledge_provider.dart git commit -m "feat(knowledge): add KnowledgeNotifier with two-tier pagination state" ``` --- ## Task 6: KnowledgeItemCard Widget **Files:** - Create: `lib/widgets/knowledge_item_card.dart` - [ ] **Step 1: Create `lib/widgets/knowledge_item_card.dart`** ```dart import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import '../data/models/knowledge_item.dart'; class KnowledgeItemCard extends StatelessWidget { final KnowledgeItem item; const KnowledgeItemCard({super.key, required this.item}); IconData get _icon => switch (item.noteType) { 'person' => Icons.person_outlined, 'place' => Icons.place_outlined, 'list' => Icons.checklist_outlined, 'task' => Icons.task_alt_outlined, _ => Icons.description_outlined, }; Color _statusColor(BuildContext context) { if (item.noteType != 'task') return Theme.of(context).colorScheme.primary; return switch (item.status) { 'done' => Colors.green, 'in_progress' => Colors.orange, 'cancelled' => Colors.grey, _ => Theme.of(context).colorScheme.primary, }; } String? get _subtitle { if (item.noteType == 'task') { if (item.dueDate != null) return 'Due ${item.dueDate}'; return item.status; } if (item.body.trim().isEmpty) return null; final preview = item.body.trim().replaceAll('\n', ' '); return preview.length > 120 ? '${preview.substring(0, 120)}…' : preview; } @override Widget build(BuildContext context) { return ListTile( leading: Icon(_icon, color: _statusColor(context)), title: Text( item.title.isEmpty ? '(untitled)' : item.title, maxLines: 1, overflow: TextOverflow.ellipsis, ), subtitle: _subtitle != null ? Text( _subtitle!, maxLines: 2, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.bodySmall, ) : null, trailing: item.tags.isNotEmpty ? _TagChips(tags: item.tags) : null, onTap: () { if (item.noteType == 'task') { context.push('/tasks/${item.id}/edit'); } else { context.push('/notes/${item.id}'); } }, ); } } class _TagChips extends StatelessWidget { final List tags; const _TagChips({required this.tags}); @override Widget build(BuildContext context) { final shown = tags.take(2).toList(); final extra = tags.length - shown.length; return Wrap( spacing: 4, children: [ for (final t in shown) Chip( label: Text(t, style: const TextStyle(fontSize: 10)), padding: EdgeInsets.zero, materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, visualDensity: VisualDensity.compact, ), if (extra > 0) Chip( label: Text('+$extra', style: const TextStyle(fontSize: 10)), padding: EdgeInsets.zero, materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, visualDensity: VisualDensity.compact, ), ], ); } } ``` - [ ] **Step 2: Analyze** ```bash flutter analyze lib/widgets/knowledge_item_card.dart ``` Expected: `No issues found!` - [ ] **Step 3: Commit** ```bash git add lib/widgets/knowledge_item_card.dart git commit -m "feat(knowledge): add KnowledgeItemCard widget" ``` --- ## Task 7: KnowledgeScreen **Files:** - Create: `lib/screens/knowledge/knowledge_screen.dart` - [ ] **Step 1: Create `lib/screens/knowledge/knowledge_screen.dart`** ```dart import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import '../../providers/knowledge_provider.dart'; import '../../widgets/knowledge_item_card.dart'; // Type tab configuration: (label, noteType filter value) const _kTabs = [ (label: 'All', type: null), (label: 'Notes', type: 'note'), (label: 'People', type: 'person'), (label: 'Places', type: 'place'), (label: 'Lists', type: 'list'), (label: 'Tasks', type: 'task'), ]; class KnowledgeScreen extends ConsumerStatefulWidget { const KnowledgeScreen({super.key}); @override ConsumerState createState() => _KnowledgeScreenState(); } class _KnowledgeScreenState extends ConsumerState with SingleTickerProviderStateMixin { late final TabController _tabController; late final ScrollController _scrollController; bool _searchActive = false; final _searchController = TextEditingController(); var _debounce = DateTime.now(); @override void initState() { super.initState(); _tabController = TabController(length: _kTabs.length, vsync: this); _scrollController = ScrollController(); _scrollController.addListener(_onScroll); // Trigger initial load WidgetsBinding.instance.addPostFrameCallback((_) { ref.read(knowledgeProvider.notifier).refresh(); }); _tabController.addListener(_onTabChanged); } @override void dispose() { _tabController.dispose(); _scrollController.dispose(); _searchController.dispose(); super.dispose(); } void _onTabChanged() { if (!_tabController.indexIsChanging) return; final newType = _kTabs[_tabController.index].type; ref.read(knowledgeProvider.notifier).setTypeFilter(newType); } void _onScroll() { final pos = _scrollController.position; if (pos.pixels >= pos.maxScrollExtent - 300) { ref.read(knowledgeProvider.notifier).hydrateNext(); } } void _onSearchChanged(String q) { final now = DateTime.now(); _debounce = now; Future.delayed(const Duration(milliseconds: 400), () { if (_debounce == now && mounted) { ref.read(knowledgeProvider.notifier).setSearch(q); } }); } String _tabLabel(int index, Map counts) { final tab = _kTabs[index]; if (tab.type == null) { final total = counts.values.fold(0, (a, b) => a + b); return total > 0 ? 'All ($total)' : 'All'; } final count = counts[tab.type]; return count != null && count > 0 ? '${tab.label} ($count)' : tab.label; } @override Widget build(BuildContext context) { final state = ref.watch(knowledgeProvider); return Scaffold( appBar: AppBar( title: _searchActive ? TextField( controller: _searchController, autofocus: true, decoration: const InputDecoration( hintText: 'Search knowledge…', border: InputBorder.none, ), onChanged: _onSearchChanged, ) : const Text('Knowledge'), actions: [ IconButton( icon: Icon(_searchActive ? Icons.close : Icons.search), onPressed: () { setState(() => _searchActive = !_searchActive); if (!_searchActive) { _searchController.clear(); ref.read(knowledgeProvider.notifier).setSearch(null); } }, ), ], bottom: TabBar( controller: _tabController, isScrollable: true, tabAlignment: TabAlignment.start, tabs: List.generate( _kTabs.length, (i) => Tab(text: _tabLabel(i, state.counts)), ), ), ), body: Column( children: [ // Tag filter chips if (state.availableTags.isNotEmpty) SizedBox( height: 48, child: ListView.separated( scrollDirection: Axis.horizontal, padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), itemCount: state.availableTags.length, separatorBuilder: (_, __) => const SizedBox(width: 6), itemBuilder: (_, i) { final tag = state.availableTags[i]; final active = state.activeTags.contains(tag); return FilterChip( label: Text(tag), selected: active, onSelected: (_) => ref.read(knowledgeProvider.notifier).toggleTag(tag), visualDensity: VisualDensity.compact, ); }, ), ), // Main list Expanded( child: _buildList(state), ), ], ), floatingActionButton: FloatingActionButton( onPressed: () => _onFabTapped(context), tooltip: 'New', child: const Icon(Icons.edit_outlined), ), ); } Widget _buildList(KnowledgeState state) { if (state.isLoadingIds && state.ids.isEmpty) { return const Center(child: CircularProgressIndicator()); } if (state.error != null && state.ids.isEmpty) { return Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ Text('Failed to load', style: Theme.of(context).textTheme.bodyLarge), const SizedBox(height: 8), TextButton( onPressed: () => ref.read(knowledgeProvider.notifier).refresh(), child: const Text('Retry'), ), ], ), ); } final items = state.orderedItems; if (items.isEmpty && !state.isLoadingIds && !state.isLoadingBatch) { return Center( child: Text( 'No ${_kTabs[_tabController.index].label.toLowerCase()} yet.', style: Theme.of(context).textTheme.bodyLarge, ), ); } return RefreshIndicator( onRefresh: () => ref.read(knowledgeProvider.notifier).refresh(), child: ListView.separated( controller: _scrollController, itemCount: items.length + (state.isLoadingBatch ? 1 : 0), separatorBuilder: (_, __) => const Divider(height: 1), itemBuilder: (_, i) { if (i >= items.length) { return const Padding( padding: EdgeInsets.all(16), child: Center(child: CircularProgressIndicator()), ); } return KnowledgeItemCard(item: items[i]); }, ), ); } void _onFabTapped(BuildContext context) { final currentType = _kTabs[_tabController.index].type; if (currentType == 'task') { context.push('/tasks/new'); return; } _showTypePicker(context); } void _showTypePicker(BuildContext context) { showModalBottomSheet( context: context, builder: (sheetContext) => SafeArea( child: Column( mainAxisSize: MainAxisSize.min, children: [ _TypePickerRow( icon: Icons.description_outlined, label: 'Note', description: 'General note or document', onTap: () { Navigator.pop(sheetContext); context.push('/notes/new', extra: {'noteType': 'note'}); }, ), _TypePickerRow( icon: Icons.person_outlined, label: 'Person', description: 'Contact, colleague, or reference person', onTap: () { Navigator.pop(sheetContext); context.push('/notes/new', extra: {'noteType': 'person'}); }, ), _TypePickerRow( icon: Icons.place_outlined, label: 'Place', description: 'Location, venue, or place of interest', onTap: () { Navigator.pop(sheetContext); context.push('/notes/new', extra: {'noteType': 'place'}); }, ), _TypePickerRow( icon: Icons.checklist_outlined, label: 'List', description: 'Checklist or structured list', onTap: () { Navigator.pop(sheetContext); context.push('/notes/new', extra: {'noteType': 'list'}); }, ), ], ), ), ); } } class _TypePickerRow extends StatelessWidget { final IconData icon; final String label; final String description; final VoidCallback onTap; const _TypePickerRow({ required this.icon, required this.label, required this.description, required this.onTap, }); @override Widget build(BuildContext context) { return ListTile( leading: Icon(icon), title: Text(label), subtitle: Text(description), onTap: onTap, ); } } ``` - [ ] **Step 2: Analyze** ```bash flutter analyze lib/screens/knowledge/knowledge_screen.dart ``` Expected: `No issues found!` - [ ] **Step 3: Commit** ```bash git add lib/screens/knowledge/knowledge_screen.dart git commit -m "feat(knowledge): add KnowledgeScreen with two-tier feed, tabs, search, and tag filters" ``` --- ## Task 8: NoteEditScreen — noteType Parameter **Files:** - Modify: `lib/screens/notes/note_edit_screen.dart` The screen needs to: 1. Accept an optional `noteType` constructor parameter for new notes 2. Load `noteType` from the existing note when editing 3. Show a type badge chip in the app bar 4. Pass `noteType` to the provider on save - [ ] **Step 1: Update `lib/screens/notes/note_edit_screen.dart`** Change the class declaration and state to: ```dart class NoteEditScreen extends ConsumerStatefulWidget { final int? noteId; final String? noteType; // passed when creating a typed note from KnowledgeScreen const NoteEditScreen({super.key, this.noteId, this.noteType}); @override ConsumerState createState() => _NoteEditScreenState(); } class _NoteEditScreenState extends ConsumerState { final _titleController = TextEditingController(); final _contentController = TextEditingController(); final _tagController = TextEditingController(); List _tags = []; int? _projectId; bool _preview = false; bool _saving = false; late String _noteType; // resolved in initState late final Future _initFuture; @override void initState() { super.initState(); _noteType = widget.noteType ?? 'note'; _initFuture = widget.noteId != null ? _loadExisting() : Future.value(); } ``` Update `_loadExisting` to also load `noteType`: ```dart Future _loadExisting() async { final note = await ref.read(notesRepositoryProvider).getOne(widget.noteId!); _titleController.text = note.title; _contentController.text = note.body; _tags = List.from(note.tags); _projectId = note.projectId; _noteType = note.noteType; } ``` Update `_save` to pass `_noteType`: ```dart Future _save() async { final title = _titleController.text.trim(); final body = _contentController.text; if (title.isEmpty) { ScaffoldMessenger.of(context) .showSnackBar(const SnackBar(content: Text('Title is required.'))); return; } setState(() => _saving = true); try { if (widget.noteId == null) { await ref.read(notesProvider.notifier).create( title, body, tags: _tags, projectId: _projectId, noteType: _noteType, ); if (mounted) context.pop(); } else { await ref.read(notesProvider.notifier).updateNote( widget.noteId!, title, body, tags: _tags, projectId: _projectId, clearProject: _projectId == null, noteType: _noteType, ); if (mounted) context.pop(); } } on AppException catch (e) { if (mounted) { ScaffoldMessenger.of(context) .showSnackBar(SnackBar(content: Text(e.message))); } } finally { if (mounted) setState(() => _saving = false); } } ``` Update `AppBar.title` to show the type badge: ```dart appBar: AppBar( title: Row( mainAxisSize: MainAxisSize.min, children: [ Text(widget.noteId == null ? 'New ${_typeName(_noteType)}' : 'Edit ${_typeName(_noteType)}'), const SizedBox(width: 8), if (_noteType != 'note') Chip( label: Text(_typeName(_noteType), style: const TextStyle(fontSize: 11)), padding: EdgeInsets.zero, visualDensity: VisualDensity.compact, materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, ), ], ), ``` Add the helper at the bottom of the class (outside the State class, as a top-level function or inside the file): ```dart String _typeName(String type) => switch (type) { 'person' => 'Person', 'place' => 'Place', 'list' => 'List', 'task' => 'Task', _ => 'Note', }; ``` Also update the GoRouter entry for `Routes.noteNew` in `app.dart` (done in Task 11) to read `extra` and pass it to `NoteEditScreen`. For now the screen compiles with the constructor as-is. - [ ] **Step 2: Analyze** ```bash flutter analyze lib/screens/notes/note_edit_screen.dart ``` Expected: `No issues found!` - [ ] **Step 3: Commit** ```bash git add lib/screens/notes/note_edit_screen.dart git commit -m "feat(knowledge): thread noteType through NoteEditScreen for typed note creation" ``` --- ## Task 9: Projects Sort + ProjectsScreen + ProjectEditScreen **Files:** - Modify: `lib/data/api/projects_api.dart` - Modify: `lib/data/repositories/projects_repository.dart` - Modify: `lib/providers/projects_provider.dart` - Create: `lib/screens/projects/projects_screen.dart` - Create: `lib/screens/projects/project_edit_screen.dart` - [ ] **Step 1: Add sort params to `lib/data/api/projects_api.dart`** Update the `getAll` method: ```dart Future> getAll({ String? status, String sort = 'updated_at', String order = 'desc', }) async { try { final response = await _dio.get( '/api/projects', queryParameters: { if (status != null) 'status': status, 'sort': sort, 'order': order, }, ); final data = response.data as Map; final list = data['projects'] as List; return list .map((e) => Project.fromJson(e as Map)) .toList(); } on DioException catch (e) { throw dioToApp(e); } } ``` - [ ] **Step 2: Update `lib/data/repositories/projects_repository.dart`** ```dart import '../api/projects_api.dart'; import '../models/project.dart'; class ProjectsRepository { final ProjectsApi _api; const ProjectsRepository(this._api); Future> getAll({ String? status, String sort = 'updated_at', String order = 'desc', }) => _api.getAll(status: status, sort: sort, order: order); Future getOne(int id) => _api.getOne(id); Future create({ required String title, String? description, String? goal, String? color, }) => _api.create( title: title, description: description, goal: goal, color: color); Future update(int id, Map fields) => _api.update(id, fields); Future delete(int id) => _api.delete(id); } ``` - [ ] **Step 3: Update `lib/providers/projects_provider.dart`** Update `build()` to pass sort params: ```dart @override Future> build() async { return ref.watch(projectsRepositoryProvider).getAll( sort: 'updated_at', order: 'desc', ); } ``` - [ ] **Step 4: Create `lib/screens/projects/projects_screen.dart`** ```dart import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import '../../data/models/project.dart'; import '../../providers/projects_provider.dart'; class ProjectsScreen extends ConsumerWidget { const ProjectsScreen({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { final projectsAsync = ref.watch(projectsProvider); return Scaffold( appBar: AppBar(title: const Text('Projects')), body: projectsAsync.when( loading: () => const Center(child: CircularProgressIndicator()), error: (e, _) => Center(child: Text('Error: $e')), data: (projects) => projects.isEmpty ? const Center(child: Text('No projects yet.')) : RefreshIndicator( onRefresh: () async => ref.invalidate(projectsProvider), child: ListView.separated( itemCount: projects.length, separatorBuilder: (_, __) => const Divider(height: 1), itemBuilder: (_, i) => _ProjectCard(project: projects[i]), ), ), ), floatingActionButton: FloatingActionButton( onPressed: () => context.push('/projects/new'), tooltip: 'New project', child: const Icon(Icons.add), ), ); } } class _ProjectCard extends StatelessWidget { final Project project; const _ProjectCard({required this.project}); Color _statusColor(BuildContext context) => switch (project.status) { 'completed' => Colors.blue, 'archived' => Colors.grey, _ => Theme.of(context).colorScheme.primary, }; @override Widget build(BuildContext context) { return ListTile( title: Text(project.title), subtitle: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (project.description?.isNotEmpty == true) Text( project.description!, maxLines: 1, overflow: TextOverflow.ellipsis, ), if (project.goal?.isNotEmpty == true) Text( project.goal!, maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context) .textTheme .bodySmall ?.copyWith(fontStyle: FontStyle.italic), ), ], ), trailing: Chip( label: Text( project.status, style: TextStyle( fontSize: 11, color: _statusColor(context), ), ), padding: EdgeInsets.zero, visualDensity: VisualDensity.compact, materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, ), onTap: () => context.push('/projects/${project.id}/tasks'), ); } } ``` - [ ] **Step 5: Create `lib/screens/projects/project_edit_screen.dart`** ```dart 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/projects_provider.dart'; class ProjectEditScreen extends ConsumerStatefulWidget { final int? projectId; const ProjectEditScreen({super.key, this.projectId}); @override ConsumerState createState() => _ProjectEditScreenState(); } class _ProjectEditScreenState extends ConsumerState { final _titleController = TextEditingController(); final _descController = TextEditingController(); final _goalController = TextEditingController(); String _status = 'active'; bool _saving = false; late final Future _initFuture; @override void initState() { super.initState(); _initFuture = widget.projectId != null ? _loadExisting() : Future.value(); } @override void dispose() { _titleController.dispose(); _descController.dispose(); _goalController.dispose(); super.dispose(); } Future _loadExisting() async { final projects = ref.read(projectsProvider).value ?? []; final project = projects.where((p) => p.id == widget.projectId).firstOrNull; if (project != null) { _titleController.text = project.title; _descController.text = project.description ?? ''; _goalController.text = project.goal ?? ''; _status = project.status; } } Future _save() async { final title = _titleController.text.trim(); if (title.isEmpty) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Title is required.'))); return; } setState(() => _saving = true); try { if (widget.projectId == null) { await ref.read(projectsProvider.notifier).create( title: title, description: _descController.text.trim().isEmpty ? null : _descController.text.trim(), goal: _goalController.text.trim().isEmpty ? null : _goalController.text.trim(), ); } else { await ref.read(projectsProvider.notifier).updateProject( widget.projectId!, { 'title': title, 'description': _descController.text.trim(), 'goal': _goalController.text.trim(), 'status': _status, }, ); } if (mounted) context.pop(); } on AppException catch (e) { if (mounted) { ScaffoldMessenger.of(context) .showSnackBar(SnackBar(content: Text(e.message))); } } finally { if (mounted) setState(() => _saving = false); } } @override Widget build(BuildContext context) { return FutureBuilder( future: _initFuture, builder: (context, snapshot) => Scaffold( appBar: AppBar( title: Text( widget.projectId == null ? 'New Project' : 'Edit Project'), actions: [ IconButton( icon: _saving ? const SizedBox( width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2), ) : const Icon(Icons.check), onPressed: _saving ? null : _save, ), ], ), body: snapshot.connectionState == ConnectionState.waiting ? const Center(child: CircularProgressIndicator()) : ListView( padding: const EdgeInsets.all(16), children: [ TextField( controller: _titleController, decoration: const InputDecoration( labelText: 'Title *', border: OutlineInputBorder()), textInputAction: TextInputAction.next, ), const SizedBox(height: 12), TextField( controller: _descController, decoration: const InputDecoration( labelText: 'Description', border: OutlineInputBorder()), maxLines: 3, textInputAction: TextInputAction.next, ), const SizedBox(height: 12), TextField( controller: _goalController, decoration: const InputDecoration( labelText: 'Goal', border: OutlineInputBorder()), maxLines: 2, textInputAction: TextInputAction.done, ), if (widget.projectId != null) ...[ const SizedBox(height: 12), DropdownButtonFormField( value: _status, decoration: const InputDecoration( labelText: 'Status', border: OutlineInputBorder()), items: const [ DropdownMenuItem( value: 'active', child: Text('Active')), DropdownMenuItem( value: 'completed', child: Text('Completed')), DropdownMenuItem( value: 'archived', child: Text('Archived')), ], onChanged: (v) => setState(() => _status = v!), ), ], ], ), ), ); } } ``` - [ ] **Step 6: Analyze** ```bash flutter analyze lib/data/api/projects_api.dart lib/data/repositories/projects_repository.dart lib/providers/projects_provider.dart lib/screens/projects/ ``` Expected: `No issues found!` - [ ] **Step 7: Commit** ```bash git add lib/data/api/projects_api.dart lib/data/repositories/projects_repository.dart lib/providers/projects_provider.dart lib/screens/projects/ git commit -m "feat(knowledge): add ProjectsScreen and ProjectEditScreen, sort projects by updated_at" ``` --- ## Task 10: ProjectTasksScreen — Edit Button **Files:** - Modify: `lib/screens/library/project_tasks_screen.dart` - [ ] **Step 1: Add edit button to AppBar** In `project_tasks_screen.dart`, find the `AppBar` widget inside the `build` method (around line 71) and add `actions`: ```dart appBar: AppBar( title: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( project?.title ?? 'Project', style: Theme.of(context).textTheme.titleLarge, ), if (project?.description?.isNotEmpty == true) Text( project!.description!, style: Theme.of(context).textTheme.labelSmall?.copyWith( color: Theme.of(context).colorScheme.onSurfaceVariant, ), maxLines: 1, overflow: TextOverflow.ellipsis, ), ], ), actions: [ IconButton( icon: const Icon(Icons.edit_outlined), tooltip: 'Edit project', onPressed: () => context.push('/projects/${widget.projectId}/edit'), ), ], ), ``` - [ ] **Step 2: Analyze** ```bash flutter analyze lib/screens/library/project_tasks_screen.dart ``` Expected: `No issues found!` - [ ] **Step 3: Commit** ```bash git add lib/screens/library/project_tasks_screen.dart git commit -m "feat(knowledge): add edit button to ProjectTasksScreen app bar" ``` --- ## Task 11: Shell Navigation — Wire 4 Tabs & Retire LibraryScreen **Files:** - Modify: `lib/app.dart` This is the final wiring task. It replaces the Library shell route with Knowledge and Projects, updates the bottom nav to 4 tabs, and passes `noteType` from `GoRouter.extra` to `NoteEditScreen`. - [ ] **Step 1: Update imports in `lib/app.dart`** Add these imports (remove the `library_screen.dart` import): ```dart import 'screens/knowledge/knowledge_screen.dart'; import 'screens/projects/projects_screen.dart'; import 'screens/projects/project_edit_screen.dart'; ``` Remove: ```dart import 'screens/library/library_screen.dart'; ``` - [ ] **Step 2: Update the GoRouter routes** Replace the `ShellRoute` block and the `Routes.projectTasks` route with: ```dart GoRoute( path: Routes.projectTasks, builder: (_, state) => ProjectTasksScreen( projectId: int.parse(state.pathParameters['id']!), ), ), GoRoute( path: '/projects/new', builder: (_, _) => const ProjectEditScreen(), ), GoRoute( path: Routes.projectEdit, builder: (_, state) => ProjectEditScreen( projectId: int.parse(state.pathParameters['id']!), ), ), ShellRoute( builder: (context, state, child) => _Shell(child: child), routes: [ GoRoute( path: Routes.briefing, builder: (_, _) => const BriefingScreen(), ), GoRoute( path: Routes.knowledge, builder: (_, _) => const KnowledgeScreen(), ), GoRoute( path: Routes.conversations, builder: (_, _) => const ConversationsTabScreen(), ), GoRoute( path: Routes.projects, builder: (_, _) => const ProjectsScreen(), ), ], ), ``` Update the `Routes.noteNew` route to pass `noteType` from `extra`: ```dart GoRoute( path: Routes.noteNew, builder: (_, state) { final extra = state.extra as Map?; return NoteEditScreen(noteType: extra?['noteType'] as String?); }, ), ``` - [ ] **Step 3: Update `_Shell._tabs` and navigation bars** Replace the `_tabs` list and all four `NavigationDestination` / `NavigationRailDestination` entries: ```dart static const _tabs = [ Routes.briefing, Routes.knowledge, Routes.conversations, Routes.projects, ]; ``` Bottom `NavigationBar` destinations: ```dart destinations: const [ NavigationDestination( icon: Icon(Icons.wb_sunny_outlined), selectedIcon: Icon(Icons.wb_sunny), label: 'Briefing', ), NavigationDestination( icon: Icon(Icons.menu_book_outlined), selectedIcon: Icon(Icons.menu_book), label: 'Knowledge', ), NavigationDestination( icon: Icon(Icons.chat_bubble_outline), selectedIcon: Icon(Icons.chat_bubble), label: 'Chat', ), NavigationDestination( icon: Icon(Icons.folder_outlined), selectedIcon: Icon(Icons.folder), label: 'Projects', ), ], ``` Wide-screen `NavigationRail` destinations: ```dart destinations: const [ NavigationRailDestination( icon: Icon(Icons.wb_sunny_outlined), selectedIcon: Icon(Icons.wb_sunny), label: Text('Briefing'), ), NavigationRailDestination( icon: Icon(Icons.menu_book_outlined), selectedIcon: Icon(Icons.menu_book), label: Text('Knowledge'), ), NavigationRailDestination( icon: Icon(Icons.chat_bubble_outline), selectedIcon: Icon(Icons.chat_bubble), label: Text('Chat'), ), NavigationRailDestination( icon: Icon(Icons.folder_outlined), selectedIcon: Icon(Icons.folder), label: Text('Projects'), ), ], ``` Update `_hintForLocation`: ```dart String _hintForLocation(String location) { if (location.startsWith(Routes.knowledge)) return 'Capture a note…'; if (location.startsWith(Routes.projects)) return 'Capture a note…'; if (location.startsWith(Routes.conversations)) return 'Ask Fabled…'; return 'Capture a note…'; } ``` - [ ] **Step 4: Analyze** ```bash flutter analyze lib/app.dart ``` Expected: `No issues found!` - [ ] **Step 5: Full build** ```bash flutter build apk --debug ``` Expected: Build succeeds with `Built build/app/outputs/flutter-apk/app-debug.apk` - [ ] **Step 6: Delete retired files** ```bash rm lib/screens/library/library_screen.dart rm lib/widgets/library_item_card.dart ``` - [ ] **Step 7: Analyze again (confirms no remaining references to deleted files)** ```bash flutter analyze ``` Expected: `No issues found!` - [ ] **Step 8: Final build** ```bash flutter build apk --debug ``` Expected: `Built build/app/outputs/flutter-apk/app-debug.apk` - [ ] **Step 9: Commit** ```bash git add -A git commit -m "feat(knowledge): wire 4-tab shell, retire LibraryScreen, complete Knowledge View feature" ```