2345 lines
66 KiB
Markdown
2345 lines
66 KiB
Markdown
# 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.**
|
|
|
|
- `<uses-permission android:name="android.permission.INTERNET"/>` — already present ✓
|
|
- `<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>` — already present ✓
|
|
- `android:usesCleartextTraffic="true"` on the `<application>` 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': <String>[],
|
|
'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<String> 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<String, dynamic> json) => Note(
|
|
id: json['id'] as int,
|
|
title: json['title'] as String? ?? '',
|
|
body: json['body'] as String? ?? '',
|
|
tags: (json['tags'] as List<dynamic>?)
|
|
?.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<String, dynamic> toJson() => {
|
|
'title': title,
|
|
'body': body,
|
|
'tags': tags,
|
|
'note_type': noteType,
|
|
'project_id': projectId,
|
|
'milestone_id': milestoneId,
|
|
};
|
|
|
|
Note copyWith({
|
|
String? title,
|
|
String? body,
|
|
List<String>? 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': <dynamic>[],
|
|
'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': <dynamic>[],
|
|
'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<List<Note>> getAll() async {
|
|
try {
|
|
final response = await _dio.get('/api/notes');
|
|
final data = response.data as Map<String, dynamic>;
|
|
final list = data['notes'] as List<dynamic>;
|
|
return list.map((e) => Note.fromJson(e as Map<String, dynamic>)).toList();
|
|
} on DioException catch (e) {
|
|
throw dioToApp(e);
|
|
}
|
|
}
|
|
|
|
Future<Note> getOne(int id) async {
|
|
try {
|
|
final response = await _dio.get('/api/notes/$id');
|
|
return Note.fromJson(response.data as Map<String, dynamic>);
|
|
} on DioException catch (e) {
|
|
throw dioToApp(e);
|
|
}
|
|
}
|
|
|
|
Future<Note> create(
|
|
String title,
|
|
String body, {
|
|
List<String> 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<String, dynamic>);
|
|
} on DioException catch (e) {
|
|
throw dioToApp(e);
|
|
}
|
|
}
|
|
|
|
Future<Note> update(
|
|
int id,
|
|
String title,
|
|
String body, {
|
|
List<String> 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<String, dynamic>);
|
|
} on DioException catch (e) {
|
|
throw dioToApp(e);
|
|
}
|
|
}
|
|
|
|
Future<void> 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<List<Note>> getAll() => _api.getAll();
|
|
Future<Note> getOne(int id) => _api.getOne(id);
|
|
|
|
Future<Note> create(
|
|
String title,
|
|
String body, {
|
|
List<String> tags = const [],
|
|
int? projectId,
|
|
String noteType = 'note',
|
|
}) =>
|
|
_api.create(title, body,
|
|
tags: tags, projectId: projectId, noteType: noteType);
|
|
|
|
Future<Note> update(
|
|
int id,
|
|
String title,
|
|
String body, {
|
|
List<String> tags = const [],
|
|
int? projectId,
|
|
bool clearProject = false,
|
|
String noteType = 'note',
|
|
}) =>
|
|
_api.update(id, title, body,
|
|
tags: tags,
|
|
projectId: projectId,
|
|
clearProject: clearProject,
|
|
noteType: noteType);
|
|
|
|
Future<void> 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, List<Note>>(NotesNotifier.new);
|
|
|
|
class NotesNotifier extends AsyncNotifier<List<Note>> {
|
|
@override
|
|
Future<List<Note>> build() async {
|
|
return ref.watch(notesRepositoryProvider).getAll();
|
|
}
|
|
|
|
Future<Note> create(
|
|
String title,
|
|
String body, {
|
|
List<String> 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<Note> updateNote(
|
|
int id,
|
|
String title,
|
|
String body, {
|
|
List<String> 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<void> 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<Note, int>((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<String> 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<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),
|
|
);
|
|
}
|
|
```
|
|
|
|
- [ ] **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': <dynamic>[],
|
|
'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': <dynamic>[],
|
|
'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': <dynamic>[],
|
|
'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': <dynamic>[],
|
|
'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': <dynamic>[],
|
|
'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': <dynamic>[],
|
|
'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>, int)> fetchIds({
|
|
String? noteType,
|
|
List<String> tags = const [],
|
|
String sort = 'modified',
|
|
String? q,
|
|
int limit = 50,
|
|
int offset = 0,
|
|
}) async {
|
|
try {
|
|
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);
|
|
} on DioException catch (e) {
|
|
throw dioToApp(e);
|
|
}
|
|
}
|
|
|
|
/// Batch-hydrate up to 100 IDs into full KnowledgeItems.
|
|
Future<List<KnowledgeItem>> fetchBatch(List<int> 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<String, dynamic>;
|
|
return (data['items'] as List<dynamic>)
|
|
.map((e) => KnowledgeItem.fromJson(e as Map<String, dynamic>))
|
|
.toList();
|
|
} on DioException catch (e) {
|
|
throw dioToApp(e);
|
|
}
|
|
}
|
|
|
|
/// Per-type counts for tab labels.
|
|
Future<Map<String, int>> fetchCounts({List<String> tags = const []}) async {
|
|
try {
|
|
final response = await _dio.get(
|
|
'/api/knowledge/counts',
|
|
queryParameters: {
|
|
if (tags.isNotEmpty) 'tags': tags.join(','),
|
|
},
|
|
);
|
|
return (response.data as Map<String, dynamic>)
|
|
.map((k, v) => MapEntry(k, (v as num).toInt()));
|
|
} on DioException catch (e) {
|
|
throw dioToApp(e);
|
|
}
|
|
}
|
|
|
|
/// Tags available for the given type filter.
|
|
Future<List<String>> 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<dynamic>)
|
|
.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>, int)> fetchIds({
|
|
String? noteType,
|
|
List<String> 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<List<KnowledgeItem>> fetchBatch(List<int> ids) =>
|
|
_api.fetchBatch(ids);
|
|
|
|
Future<Map<String, int>> fetchCounts({List<String> tags = const []}) =>
|
|
_api.fetchCounts(tags: tags);
|
|
|
|
Future<List<String>> 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<KnowledgeApi>((ref) {
|
|
return KnowledgeApi(ref.watch(dioProvider));
|
|
});
|
|
|
|
final knowledgeRepositoryProvider = Provider<KnowledgeRepository>((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<int> ids;
|
|
final Map<int, KnowledgeItem> items;
|
|
final int totalIds;
|
|
final bool isLoadingIds;
|
|
final bool isLoadingBatch;
|
|
final bool hasMore;
|
|
final String? noteType; // null = All
|
|
final List<String> activeTags;
|
|
final String? searchQuery;
|
|
final Map<String, int> counts;
|
|
final List<String> 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<KnowledgeItem> get orderedItems =>
|
|
ids.where(items.containsKey).map((id) => items[id]!).toList();
|
|
|
|
/// IDs that have been fetched but not yet hydrated.
|
|
List<int> get unhydratedIds =>
|
|
ids.where((id) => !items.containsKey(id)).toList();
|
|
|
|
KnowledgeState copyWith({
|
|
List<int>? ids,
|
|
Map<int, KnowledgeItem>? items,
|
|
int? totalIds,
|
|
bool? isLoadingIds,
|
|
bool? isLoadingBatch,
|
|
bool? hasMore,
|
|
Object? noteType = _keep,
|
|
List<String>? activeTags,
|
|
Object? searchQuery = _keep,
|
|
Map<String, int>? counts,
|
|
List<String>? 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<KnowledgeState> {
|
|
KnowledgeNotifier(this._ref) : super(const KnowledgeState());
|
|
|
|
final Ref _ref;
|
|
|
|
KnowledgeRepository get _repo => _ref.read(knowledgeRepositoryProvider);
|
|
|
|
// ── Filter setters — each resets and re-fetches ──────────────────────────
|
|
|
|
Future<void> setTypeFilter(String? noteType) async {
|
|
state = KnowledgeState(noteType: noteType, activeTags: state.activeTags);
|
|
await _fetchFromScratch();
|
|
}
|
|
|
|
Future<void> 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<void> 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<void> 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<void> 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<int, KnowledgeItem>.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<void> _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<void> _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<void> _loadCounts() async {
|
|
try {
|
|
final counts = await _repo.fetchCounts(tags: state.activeTags);
|
|
state = state.copyWith(counts: counts);
|
|
} catch (_) {}
|
|
}
|
|
|
|
Future<void> _loadTags() async {
|
|
try {
|
|
final tags = await _repo.fetchTags(noteType: state.noteType);
|
|
state = state.copyWith(availableTags: tags);
|
|
} catch (_) {}
|
|
}
|
|
}
|
|
|
|
final knowledgeProvider =
|
|
StateNotifierProvider<KnowledgeNotifier, KnowledgeState>(
|
|
(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<String> 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<KnowledgeScreen> createState() => _KnowledgeScreenState();
|
|
}
|
|
|
|
class _KnowledgeScreenState extends ConsumerState<KnowledgeScreen>
|
|
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<String, int> 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<void>(
|
|
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<NoteEditScreen> createState() => _NoteEditScreenState();
|
|
}
|
|
|
|
class _NoteEditScreenState extends ConsumerState<NoteEditScreen> {
|
|
final _titleController = TextEditingController();
|
|
final _contentController = TextEditingController();
|
|
final _tagController = TextEditingController();
|
|
List<String> _tags = [];
|
|
int? _projectId;
|
|
bool _preview = false;
|
|
bool _saving = false;
|
|
late String _noteType; // resolved in initState
|
|
|
|
late final Future<void> _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<void> _loadExisting() async {
|
|
final note =
|
|
await ref.read(notesRepositoryProvider).getOne(widget.noteId!);
|
|
_titleController.text = note.title;
|
|
_contentController.text = note.body;
|
|
_tags = List<String>.from(note.tags);
|
|
_projectId = note.projectId;
|
|
_noteType = note.noteType;
|
|
}
|
|
```
|
|
|
|
Update `_save` to pass `_noteType`:
|
|
|
|
```dart
|
|
Future<void> _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<List<Project>> 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<String, dynamic>;
|
|
final list = data['projects'] as List<dynamic>;
|
|
return list
|
|
.map((e) => Project.fromJson(e as Map<String, dynamic>))
|
|
.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<List<Project>> getAll({
|
|
String? status,
|
|
String sort = 'updated_at',
|
|
String order = 'desc',
|
|
}) =>
|
|
_api.getAll(status: status, sort: sort, order: order);
|
|
|
|
Future<Project> getOne(int id) => _api.getOne(id);
|
|
|
|
Future<Project> create({
|
|
required String title,
|
|
String? description,
|
|
String? goal,
|
|
String? color,
|
|
}) =>
|
|
_api.create(
|
|
title: title, description: description, goal: goal, color: color);
|
|
|
|
Future<Project> update(int id, Map<String, dynamic> fields) =>
|
|
_api.update(id, fields);
|
|
|
|
Future<void> delete(int id) => _api.delete(id);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Update `lib/providers/projects_provider.dart`**
|
|
|
|
Update `build()` to pass sort params:
|
|
|
|
```dart
|
|
@override
|
|
Future<List<Project>> 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<ProjectEditScreen> createState() => _ProjectEditScreenState();
|
|
}
|
|
|
|
class _ProjectEditScreenState extends ConsumerState<ProjectEditScreen> {
|
|
final _titleController = TextEditingController();
|
|
final _descController = TextEditingController();
|
|
final _goalController = TextEditingController();
|
|
String _status = 'active';
|
|
bool _saving = false;
|
|
late final Future<void> _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<void> _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<void> _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<String>(
|
|
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<String, dynamic>?;
|
|
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"
|
|
```
|