# 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