ac4b2359a5
Covers four-tab navigation (Briefing/Knowledge/Chat/Projects), two-tier pagination (50 IDs / 12-item batches), type-aware cards, bottom sheet type picker, Projects screen with sorted project list, ProjectEditScreen, and manifest/config checklist. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
456 lines
16 KiB
Markdown
456 lines
16 KiB
Markdown
# Knowledge View — Android App Design Spec
|
|
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task.
|
|
|
|
**Goal:** Replace the Library tab with a typed Knowledge view and add a dedicated Projects tab, bringing the Android app to parity with the web Knowledge view while using the existing backend `/api/knowledge` and `/api/projects` endpoints.
|
|
|
|
**Architecture:** Two-tier pagination — fetch IDs cheaply (50 at a time), hydrate visible items in batches of 12. Type tabs drive server-side filtering. A new Projects tab replaces the project section formerly in Library. All data access goes through the existing Riverpod/Dio/repository pattern.
|
|
|
|
**Tech Stack:** Flutter 3, Riverpod 3, GoRouter 17, Dio 5, existing backend REST API (`/api/knowledge`, `/api/projects`, `/api/notes`)
|
|
|
|
---
|
|
|
|
## Manifest & Configuration Checklist
|
|
|
|
These must be verified before any code is written. Failures here cause silent runtime errors.
|
|
|
|
- [ ] `android/app/src/main/AndroidManifest.xml` — confirm `<uses-permission android:name="android.permission.INTERNET" />` is present
|
|
- [ ] `android/app/src/main/AndroidManifest.xml` — confirm `android:usesCleartextTraffic="true"` is set on the `<application>` tag (required for HTTP dev server connections; if already using HTTPS only, leave as-is but document the decision)
|
|
- [ ] `pubspec.yaml` — no new dependencies required for Knowledge View
|
|
- [ ] `android/app/build.gradle` — no changes required for Knowledge View
|
|
|
|
---
|
|
|
|
## File Map
|
|
|
|
### New files
|
|
| Path | Responsibility |
|
|
|---|---|
|
|
| `lib/data/models/knowledge_item.dart` | Unified model for all knowledge types (note, person, place, list, task) |
|
|
| `lib/data/api/knowledge_api.dart` | Two API methods: fetch IDs, batch-hydrate items |
|
|
| `lib/data/repositories/knowledge_repository.dart` | Thin repo wrapping KnowledgeApi |
|
|
| `lib/providers/knowledge_provider.dart` | StateNotifier with two-tier pagination state |
|
|
| `lib/screens/knowledge/knowledge_screen.dart` | Main Knowledge screen with type tabs, tag chips, search, infinite scroll |
|
|
| `lib/widgets/knowledge_item_card.dart` | Type-aware card widget (different icon/subtitle per type) |
|
|
| `lib/screens/projects/projects_screen.dart` | Project list sorted by updated_at desc |
|
|
| `lib/screens/projects/project_edit_screen.dart` | Create/edit project (title, description, goal, status) |
|
|
|
|
### Modified files
|
|
| Path | Change |
|
|
|---|---|
|
|
| `lib/data/models/note.dart` | Add `noteType` field (String, default `'note'`) |
|
|
| `lib/data/models/project.dart` | Add `status`, `goal`, `color`, `autoSummary`, `updatedAt` fields |
|
|
| `lib/data/api/notes_api.dart` | Pass `note_type` in create and update request bodies |
|
|
| `lib/data/api/projects_api.dart` | Add `sort=updated_at&order=desc` to list call; add `status`, `goal`, `color` to create/update |
|
|
| `lib/screens/notes/note_edit_screen.dart` | Accept optional `noteType` parameter; show type badge in app bar; pass `note_type` to API |
|
|
| `lib/screens/library/project_tasks_screen.dart` | Add edit button in app bar pushing to `ProjectEditScreen` |
|
|
| `lib/app.dart` | Replace `Routes.library` with `Routes.knowledge` + `Routes.projects`; update shell to 4 tabs |
|
|
| `lib/core/constants.dart` | Add `Routes.knowledge`, `Routes.projects`; remove `Routes.library` |
|
|
|
|
### Retired files
|
|
| Path | Replacement |
|
|
|---|---|
|
|
| `lib/screens/library/library_screen.dart` | `KnowledgeScreen` + `ProjectsScreen` |
|
|
| `lib/widgets/library_item_card.dart` | `KnowledgeItemCard` |
|
|
|
|
---
|
|
|
|
## Section 1: Navigation
|
|
|
|
Four tabs replace the existing three:
|
|
|
|
```
|
|
Briefing | Knowledge | Chat | Projects
|
|
```
|
|
|
|
**`lib/core/constants.dart`:**
|
|
- Remove `static const library = '/library'`
|
|
- Add `static const knowledge = '/knowledge'`
|
|
- `projects` already exists as `'/projects'` — no change needed
|
|
- Add `static const projectEdit = '/projects/:id/edit'`
|
|
|
|
**`lib/app.dart` — `_ShellState`:**
|
|
```dart
|
|
static const _tabs = [
|
|
Routes.briefing,
|
|
Routes.knowledge,
|
|
Routes.conversations,
|
|
Routes.projects,
|
|
];
|
|
```
|
|
|
|
Shell `NavigationBar` / `NavigationRail` entries:
|
|
```dart
|
|
// Bottom nav
|
|
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'),
|
|
```
|
|
|
|
**`_QuickCaptureBar._hintForLocation` update:**
|
|
```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…';
|
|
}
|
|
```
|
|
|
|
**GoRouter shell routes** — replace `Routes.library` route with:
|
|
```dart
|
|
GoRoute(path: Routes.knowledge, builder: (_, _) => const KnowledgeScreen()),
|
|
GoRoute(path: Routes.projects, builder: (_, _) => const ProjectsScreen()),
|
|
```
|
|
|
|
---
|
|
|
|
## Section 2: Data Models
|
|
|
|
### `lib/data/models/knowledge_item.dart`
|
|
```dart
|
|
class KnowledgeItem {
|
|
final int id;
|
|
final String noteType; // 'note' | 'person' | 'place' | 'list' | 'task'
|
|
final String title;
|
|
final String body;
|
|
final List<String> tags;
|
|
final int? projectId;
|
|
final int? milestoneId;
|
|
final int? parentId;
|
|
// Task-only fields (null for non-tasks)
|
|
final String? status; // 'todo' | 'in_progress' | 'done' | 'cancelled'
|
|
final String? priority; // 'low' | 'normal' | 'high'
|
|
final String? dueDate;
|
|
final DateTime createdAt;
|
|
final DateTime updatedAt;
|
|
|
|
const KnowledgeItem({...});
|
|
|
|
factory KnowledgeItem.fromJson(Map<String, dynamic> 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<dynamic>?)?.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),
|
|
);
|
|
}
|
|
```
|
|
|
|
### `lib/data/models/note.dart` — add `noteType`
|
|
```dart
|
|
final String noteType; // new field
|
|
|
|
// In constructor:
|
|
required this.noteType,
|
|
|
|
// In fromJson:
|
|
noteType: json['note_type'] as String? ?? 'note',
|
|
|
|
// In toJson:
|
|
'note_type': noteType,
|
|
|
|
// In copyWith: add noteType parameter
|
|
```
|
|
|
|
### `lib/data/models/project.dart` — add missing fields
|
|
```dart
|
|
final String status; // 'active' | 'completed' | 'archived'
|
|
final String? goal;
|
|
final String? color;
|
|
final String? autoSummary;
|
|
final DateTime updatedAt;
|
|
|
|
// In fromJson:
|
|
status: json['status'] as String? ?? 'active',
|
|
goal: json['goal'] as String?,
|
|
color: json['color'] as String?,
|
|
autoSummary: json['auto_summary'] as String?,
|
|
updatedAt: DateTime.parse(json['updated_at'] as String),
|
|
```
|
|
|
|
---
|
|
|
|
## Section 3: API Layer
|
|
|
|
### `lib/data/api/knowledge_api.dart`
|
|
```dart
|
|
class KnowledgeApi {
|
|
final Dio _dio;
|
|
const KnowledgeApi(this._dio);
|
|
|
|
/// Fetch page of IDs. Returns (ids, total).
|
|
Future<(List<int>, int)> fetchIds({
|
|
String? noteType,
|
|
List<String> tags = const [],
|
|
String sort = 'modified',
|
|
String? q,
|
|
int limit = 50,
|
|
int offset = 0,
|
|
}) async {
|
|
final params = <String, dynamic>{
|
|
'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<String, dynamic>;
|
|
final ids = (data['ids'] as List<dynamic>).map((e) => e as int).toList();
|
|
final total = data['total'] as int;
|
|
return (ids, total);
|
|
}
|
|
|
|
/// Batch-hydrate up to 100 IDs into full items.
|
|
Future<List<KnowledgeItem>> fetchBatch(List<int> ids) async {
|
|
if (ids.isEmpty) return [];
|
|
final response = await _dio.get(
|
|
'/api/knowledge/batch',
|
|
queryParameters: {'ids': ids.join(',')},
|
|
);
|
|
final data = response.data as Map<String, dynamic>;
|
|
return (data['items'] as List<dynamic>)
|
|
.map((e) => KnowledgeItem.fromJson(e as Map<String, dynamic>))
|
|
.toList();
|
|
}
|
|
|
|
/// Per-type counts for tab labels.
|
|
Future<Map<String, int>> fetchCounts({List<String> tags = const []}) async {
|
|
final params = <String, dynamic>{
|
|
if (tags.isNotEmpty) 'tags': tags.join(','),
|
|
};
|
|
final response = await _dio.get('/api/knowledge/counts', queryParameters: params);
|
|
return (response.data as Map<String, dynamic>).map(
|
|
(k, v) => MapEntry(k, (v as num).toInt()),
|
|
);
|
|
}
|
|
|
|
/// All tags for the current type filter.
|
|
Future<List<String>> fetchTags({String? noteType}) async {
|
|
final response = await _dio.get(
|
|
'/api/knowledge/tags',
|
|
queryParameters: {if (noteType != null) 'type': noteType},
|
|
);
|
|
return (response.data['tags'] as List<dynamic>).map((e) => e as String).toList();
|
|
}
|
|
}
|
|
```
|
|
|
|
### `lib/data/api/projects_api.dart` — add sort params + missing fields
|
|
Add `sort: 'updated_at'` and `order: 'desc'` to the `getProjects` query parameters.
|
|
Add `status`, `goal`, `color` to `createProject` and `updateProject` request bodies.
|
|
|
|
### `lib/data/api/notes_api.dart` — pass `note_type`
|
|
```dart
|
|
// In createNote and updateNote bodies:
|
|
if (noteType != null) 'note_type': noteType,
|
|
```
|
|
|
|
---
|
|
|
|
## Section 4: Provider
|
|
|
|
### `lib/providers/knowledge_provider.dart`
|
|
|
|
```dart
|
|
@immutable
|
|
class KnowledgeState {
|
|
final List<int> ids; // all fetched IDs so far
|
|
final Map<int, KnowledgeItem> items; // hydrated items
|
|
final int totalIds;
|
|
final bool isLoadingIds;
|
|
final bool isLoadingBatch;
|
|
final bool hasMore;
|
|
final String? noteType; // active type filter
|
|
final List<String> activeTags;
|
|
final String? searchQuery;
|
|
final Map<String, int> counts; // per-type counts
|
|
|
|
bool get canLoadMore => hasMore && !isLoadingIds;
|
|
// Items in ID order, only those hydrated
|
|
List<KnowledgeItem> get orderedItems =>
|
|
ids.where(items.containsKey).map((id) => items[id]!).toList();
|
|
}
|
|
|
|
class KnowledgeNotifier extends StateNotifier<KnowledgeState> {
|
|
// On filter change: reset state, fetch IDs from offset 0
|
|
void setTypeFilter(String? noteType) { ... }
|
|
void toggleTag(String tag) { ... }
|
|
void setSearch(String? q) { ... } // debounce handled in UI
|
|
|
|
// Fetch next page of IDs (50 at a time)
|
|
Future<void> loadMoreIds() { ... }
|
|
|
|
// Hydrate the next 12 un-hydrated IDs from the current list
|
|
Future<void> hydrateNext() { ... }
|
|
|
|
Future<void> refresh() { ... } // reset + reload
|
|
}
|
|
|
|
// Provider
|
|
final knowledgeProvider =
|
|
StateNotifierProvider<KnowledgeNotifier, KnowledgeState>(...);
|
|
```
|
|
|
|
**Scroll trigger:** `KnowledgeScreen` attaches a `ScrollController` listener. When `position.pixels >= maxScrollExtent - 300`:
|
|
1. Call `hydrateNext()` if there are un-hydrated IDs in the list
|
|
2. Call `loadMoreIds()` if all fetched IDs are hydrated and `hasMore` is true
|
|
|
|
---
|
|
|
|
## Section 5: Knowledge Screen
|
|
|
|
### `lib/screens/knowledge/knowledge_screen.dart`
|
|
|
|
**Structure:**
|
|
```
|
|
Scaffold
|
|
AppBar
|
|
title: 'Knowledge'
|
|
actions: [search icon → expand TextField]
|
|
Column
|
|
TabBar (All | Notes | People | Places | Lists | Tasks)
|
|
— tab labels show counts: "Notes (12)"
|
|
AnimatedContainer (tag filter chip row, hidden when empty)
|
|
— horizontal SingleChildScrollView of FilterChip widgets
|
|
Expanded
|
|
ListView.builder
|
|
— items from knowledgeState.orderedItems
|
|
— trailing: loading indicator when isLoadingBatch
|
|
FAB (pen icon)
|
|
— Tasks tab → push TaskEditScreen
|
|
— all other tabs → showModalBottomSheet (type picker)
|
|
```
|
|
|
|
**Pull-to-refresh:** `RefreshIndicator` wrapping the `ListView`.
|
|
|
|
**Empty state:** Centered column with type-appropriate icon and "No [type] yet" text.
|
|
|
|
**Error state:** Centered text with retry button calling `ref.invalidate(knowledgeProvider)`.
|
|
|
|
### `lib/widgets/knowledge_item_card.dart`
|
|
|
|
`ListTile`-based card. Leading icon varies by type:
|
|
- note → `Icons.description_outlined`
|
|
- person → `Icons.person_outlined`
|
|
- place → `Icons.place_outlined`
|
|
- list → `Icons.checklist_outlined`
|
|
- task → `Icons.task_alt_outlined` (with status colour on leading)
|
|
|
|
Subtitle shows truncated body (max 2 lines) or due date for tasks.
|
|
Trailing shows tag chips (up to 2, then "+N more").
|
|
|
|
Tap → `NoteDetailScreen(noteId: item.id)` for knowledge types; `TaskEditScreen(taskId: item.id)` for tasks.
|
|
|
|
---
|
|
|
|
## Section 6: Type Picker Bottom Sheet
|
|
|
|
Shown when FAB is tapped on any non-Tasks tab.
|
|
|
|
```dart
|
|
showModalBottomSheet(
|
|
context: context,
|
|
builder: (_) => Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
_TypeRow(Icons.description_outlined, 'Note', 'General note or document', 'note'),
|
|
_TypeRow(Icons.person_outlined, 'Person', 'Contact, colleague, or reference person', 'person'),
|
|
_TypeRow(Icons.place_outlined, 'Place', 'Location, venue, or place of interest', 'place'),
|
|
_TypeRow(Icons.checklist_outlined, 'List', 'Checklist or structured list', 'list'),
|
|
],
|
|
),
|
|
);
|
|
```
|
|
|
|
Each `_TypeRow` on tap:
|
|
1. `Navigator.pop(context)`
|
|
2. `context.push(Routes.noteNew, extra: {'noteType': selectedType})`
|
|
|
|
### `lib/screens/notes/note_edit_screen.dart` changes
|
|
- Accept `noteType` from `GoRouterState.extra` or route parameter (default `'note'`)
|
|
- Display a read-only type badge chip in the app bar subtitle
|
|
- Pass `noteType` to `notes_api.createNote` / `notes_api.updateNote`
|
|
|
|
---
|
|
|
|
## Section 7: Projects Screen
|
|
|
|
### `lib/screens/projects/projects_screen.dart`
|
|
|
|
```
|
|
Scaffold
|
|
AppBar: 'Projects'
|
|
RefreshIndicator
|
|
ListView.builder
|
|
— projects from projectsProvider (sorted updated_at desc via API param)
|
|
— each item: _ProjectCard
|
|
FAB → push ProjectEditScreen()
|
|
```
|
|
|
|
`_ProjectCard` (inline widget):
|
|
- Title + status badge (`active` = green, `completed` = blue, `archived` = grey)
|
|
- Description (1 line, truncated)
|
|
- Goal text if present (italic, muted)
|
|
- Milestone progress: `"3 / 5 milestones done"` using milestone count from project data if available, otherwise omitted
|
|
|
|
Tap → `context.push('/projects/${project.id}/tasks')` — `ProjectTasksScreen` reads `projectId` from the path parameter, unchanged except for the added edit button.
|
|
|
|
### `lib/screens/projects/project_edit_screen.dart`
|
|
|
|
Fields: Title (required), Description, Goal, Status dropdown (`active` / `completed` / `archived`).
|
|
Used for both create (no `projectId`) and edit (with `projectId`).
|
|
On save: POST `/api/projects` or PATCH `/api/projects/:id` via `projectsApi`.
|
|
On success: `ref.invalidate(projectsProvider)` then `Navigator.pop`.
|
|
|
|
### `lib/screens/library/project_tasks_screen.dart` change
|
|
Add an edit `IconButton` in the `AppBar.actions`:
|
|
```dart
|
|
IconButton(
|
|
icon: const Icon(Icons.edit_outlined),
|
|
onPressed: () => context.push('/projects/$projectId/edit'),
|
|
)
|
|
```
|
|
|
|
---
|
|
|
|
## Section 8: Provider & API Wiring
|
|
|
|
New providers to add to `lib/providers/`:
|
|
|
|
```dart
|
|
// knowledge_api_provider.dart (or in api_client_provider.dart)
|
|
final knowledgeApiProvider = Provider((ref) =>
|
|
KnowledgeApi(ref.watch(dioProvider)));
|
|
|
|
final knowledgeRepositoryProvider = Provider((ref) =>
|
|
KnowledgeRepository(ref.watch(knowledgeApiProvider)));
|
|
```
|
|
|
|
`projectsProvider` — add query parameters to the underlying `getProjects` call:
|
|
```dart
|
|
await api.getProjects(sort: 'updated_at', order: 'desc');
|
|
```
|
|
|
|
---
|
|
|
|
## What This Does NOT Include
|
|
|
|
- Voice I/O (separate spec)
|
|
- Backend parity pass (separate spec)
|
|
- Editing knowledge item type after creation
|
|
- Bulk delete / multi-select in Knowledge screen
|
|
- Knowledge graph view
|
|
- Milestone detail screen (projects show milestone count only)
|