From d97f7b0ebd88fdfddfe20e1af7550ff5e2e232b6 Mon Sep 17 00:00:00 2001 From: bvandeusen Date: Sun, 5 Apr 2026 00:03:23 -0400 Subject: [PATCH] feat(knowledge): wire 4-tab shell, retire LibraryScreen, complete Knowledge View feature --- .../plans/2026-04-04-knowledge-view.md | 2344 +++++++++++++++++ lib/app.dart | 56 +- lib/screens/library/library_screen.dart | 294 --- lib/widgets/library_item_card.dart | 272 -- linux/flutter/generated_plugin_registrant.cc | 8 + linux/flutter/generated_plugins.cmake | 2 + macos/Flutter/GeneratedPluginRegistrant.swift | 4 + pubspec.lock | 64 + .../flutter/generated_plugin_registrant.cc | 9 + windows/flutter/generated_plugins.cmake | 3 + 10 files changed, 2477 insertions(+), 579 deletions(-) create mode 100644 docs/superpowers/plans/2026-04-04-knowledge-view.md delete mode 100644 lib/screens/library/library_screen.dart delete mode 100644 lib/widgets/library_item_card.dart diff --git a/docs/superpowers/plans/2026-04-04-knowledge-view.md b/docs/superpowers/plans/2026-04-04-knowledge-view.md new file mode 100644 index 0000000..ac2fe0c --- /dev/null +++ b/docs/superpowers/plans/2026-04-04-knowledge-view.md @@ -0,0 +1,2344 @@ +# Knowledge View Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the Library tab with a typed Knowledge view, add a Projects tab, and wire a two-tier paginated feed (IDs + batch hydration) backed by `/api/knowledge`. + +**Architecture:** Four-tab shell (Briefing / Knowledge / Chat / Projects). Knowledge screen fetches up to 50 IDs per page then hydrates 12 items at a time via batch endpoint. Type tabs filter server-side. Projects screen lists all projects sorted by `updated_at desc` with a dedicated create/edit screen. All state managed with Riverpod StateNotifier / AsyncNotifier following existing app patterns. + +**Tech Stack:** Flutter 3, Riverpod 3, GoRouter 17, Dio 5. No new pubspec dependencies. + +--- + +## Manifest Pre-Check (do this before Task 1) + +Open `android/app/src/main/AndroidManifest.xml` and confirm all three of the following are present. **If any are missing, add them before proceeding.** + +- `` — already present ✓ +- `` — already present ✓ +- `android:usesCleartextTraffic="true"` on the `` tag — already present ✓ + +No manifest changes required for this feature. + +--- + +## File Map + +| Action | File | +|---|---| +| Modify | `lib/core/constants.dart` | +| Modify | `lib/data/models/note.dart` | +| Modify | `lib/data/api/notes_api.dart` | +| Modify | `lib/data/repositories/notes_repository.dart` | +| Modify | `lib/providers/notes_provider.dart` | +| Modify | `lib/screens/notes/note_edit_screen.dart` | +| Create | `lib/data/models/knowledge_item.dart` | +| Create | `lib/data/api/knowledge_api.dart` | +| Create | `lib/data/repositories/knowledge_repository.dart` | +| Modify | `lib/providers/api_client_provider.dart` | +| Create | `lib/providers/knowledge_provider.dart` | +| Create | `lib/widgets/knowledge_item_card.dart` | +| Create | `lib/screens/knowledge/knowledge_screen.dart` | +| Modify | `lib/data/api/projects_api.dart` | +| Modify | `lib/data/repositories/projects_repository.dart` | +| Modify | `lib/providers/projects_provider.dart` | +| Create | `lib/screens/projects/projects_screen.dart` | +| Create | `lib/screens/projects/project_edit_screen.dart` | +| Modify | `lib/screens/library/project_tasks_screen.dart` | +| Modify | `lib/app.dart` | +| Delete | `lib/screens/library/library_screen.dart` | +| Delete | `lib/widgets/library_item_card.dart` | + +--- + +## Task 1: Route Constants + +**Files:** +- Modify: `lib/core/constants.dart` + +- [ ] **Step 1: Update constants** + +Replace the contents of `lib/core/constants.dart` with: + +```dart +abstract class Routes { + static const splash = '/'; + static const setup = '/setup'; + static const login = '/login'; + static const notes = '/notes'; + static const noteDetail = '/notes/:id'; + static const noteEdit = '/notes/:id/edit'; + static const noteNew = '/notes/new'; + static const tasks = '/tasks'; + static const taskNew = '/tasks/new'; + static const taskEdit = '/tasks/:id/edit'; + static const knowledge = '/knowledge'; + static const projects = '/projects'; + static const projectEdit = '/projects/:id/edit'; + static const conversations = '/chat'; + static const chat = '/chat/:id'; + static const quickCapture = '/quick-capture'; + static const settings = '/settings'; + static const briefing = '/briefing'; + static const projectTasks = '/projects/:id/tasks'; +} +``` + +Changes: `library` removed, `knowledge` added, `projectEdit` added. + +- [ ] **Step 2: Verify** + +```bash +cd /home/bvandeusen/Nextcloud/Projects/fabled_app +flutter analyze lib/core/constants.dart +``` + +Expected: `No issues found!` + +- [ ] **Step 3: Commit** + +```bash +git add lib/core/constants.dart +git commit -m "feat(knowledge): add knowledge + projectEdit route constants" +``` + +--- + +## Task 2: Note Model — Add noteType + +**Files:** +- Modify: `lib/data/models/note.dart` +- Modify: `lib/data/api/notes_api.dart` +- Modify: `lib/data/repositories/notes_repository.dart` +- Modify: `lib/providers/notes_provider.dart` +- Modify: `test/widget_test.dart` + +- [ ] **Step 1: Write a failing test** + +Replace `test/widget_test.dart` with: + +```dart +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('Note.fromJson', () { + test('parses noteType when present', () { + final json = { + 'id': 1, + 'title': 'Alice', + 'body': '', + 'tags': [], + 'note_type': 'person', + 'created_at': '2024-01-01T00:00:00', + 'updated_at': '2024-01-01T00:00:00', + }; + // Note class is not imported yet — this will fail to compile + // until Task 2 Step 2 is complete. + }); + }); +} +``` + +Run to confirm it fails: + +```bash +flutter test test/widget_test.dart +``` + +Expected: compilation error (Note not imported) — confirms test is wired. + +- [ ] **Step 2: Update `lib/data/models/note.dart`** + +```dart +class Note { + final int id; + final String title; + final String body; + final List tags; + final String noteType; + final int? projectId; + final int? milestoneId; + final DateTime createdAt; + final DateTime updatedAt; + + const Note({ + required this.id, + required this.title, + required this.body, + required this.tags, + this.noteType = 'note', + this.projectId, + this.milestoneId, + required this.createdAt, + required this.updatedAt, + }); + + factory Note.fromJson(Map json) => Note( + id: json['id'] as int, + title: json['title'] as String? ?? '', + body: json['body'] as String? ?? '', + tags: (json['tags'] as List?) + ?.map((e) => e as String) + .toList() ?? + [], + noteType: json['note_type'] as String? ?? 'note', + projectId: json['project_id'] as int?, + milestoneId: json['milestone_id'] as int?, + createdAt: DateTime.parse(json['created_at'] as String), + updatedAt: DateTime.parse(json['updated_at'] as String), + ); + + Map toJson() => { + 'title': title, + 'body': body, + 'tags': tags, + 'note_type': noteType, + 'project_id': projectId, + 'milestone_id': milestoneId, + }; + + Note copyWith({ + String? title, + String? body, + List? tags, + String? noteType, + Object? projectId = _undefined, + Object? milestoneId = _undefined, + }) => + Note( + id: id, + title: title ?? this.title, + body: body ?? this.body, + tags: tags ?? this.tags, + noteType: noteType ?? this.noteType, + projectId: identical(projectId, _undefined) + ? this.projectId + : projectId as int?, + milestoneId: identical(milestoneId, _undefined) + ? this.milestoneId + : milestoneId as int?, + createdAt: createdAt, + updatedAt: updatedAt, + ); + + static const _undefined = Object(); +} +``` + +- [ ] **Step 3: Write the real test and verify it passes** + +Replace `test/widget_test.dart`: + +```dart +import 'package:fabled_app/data/models/note.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('Note.fromJson', () { + test('parses noteType when present', () { + final json = { + 'id': 1, + 'title': 'Alice', + 'body': '', + 'tags': [], + 'note_type': 'person', + 'created_at': '2024-01-01T00:00:00', + 'updated_at': '2024-01-01T00:00:00', + }; + final note = Note.fromJson(json); + expect(note.noteType, equals('person')); + }); + + test('defaults noteType to note when absent', () { + final json = { + 'id': 2, + 'title': 'My note', + 'body': '', + 'tags': [], + 'created_at': '2024-01-01T00:00:00', + 'updated_at': '2024-01-01T00:00:00', + }; + final note = Note.fromJson(json); + expect(note.noteType, equals('note')); + }); + }); +} +``` + +```bash +flutter test test/widget_test.dart +``` + +Expected: `All tests passed!` + +- [ ] **Step 4: Update `lib/data/api/notes_api.dart`** + +Add `noteType` parameter to `create` and `update`: + +```dart +import 'package:dio/dio.dart'; + +import '../models/note.dart'; +import 'api_client.dart'; + +class NotesApi { + final Dio _dio; + const NotesApi(this._dio); + + Future> getAll() async { + try { + final response = await _dio.get('/api/notes'); + final data = response.data as Map; + final list = data['notes'] as List; + return list.map((e) => Note.fromJson(e as Map)).toList(); + } on DioException catch (e) { + throw dioToApp(e); + } + } + + Future getOne(int id) async { + try { + final response = await _dio.get('/api/notes/$id'); + return Note.fromJson(response.data as Map); + } on DioException catch (e) { + throw dioToApp(e); + } + } + + Future create( + String title, + String body, { + List tags = const [], + int? projectId, + String noteType = 'note', + }) async { + try { + final response = await _dio.post('/api/notes', data: { + 'title': title, + 'body': body, + 'tags': tags, + 'note_type': noteType, + if (projectId != null) 'project_id': projectId, + }); + return Note.fromJson(response.data as Map); + } on DioException catch (e) { + throw dioToApp(e); + } + } + + Future update( + int id, + String title, + String body, { + List tags = const [], + int? projectId, + bool clearProject = false, + String noteType = 'note', + }) async { + try { + final response = await _dio.put('/api/notes/$id', data: { + 'title': title, + 'body': body, + 'tags': tags, + 'note_type': noteType, + if (clearProject) 'project_id': null + else if (projectId != null) 'project_id': projectId, + }); + return Note.fromJson(response.data as Map); + } on DioException catch (e) { + throw dioToApp(e); + } + } + + Future delete(int id) async { + try { + await _dio.delete('/api/notes/$id'); + } on DioException catch (e) { + throw dioToApp(e); + } + } +} +``` + +- [ ] **Step 5: Update `lib/data/repositories/notes_repository.dart`** + +```dart +import '../api/notes_api.dart'; +import '../models/note.dart'; + +class NotesRepository { + final NotesApi _api; + const NotesRepository(this._api); + + Future> getAll() => _api.getAll(); + Future getOne(int id) => _api.getOne(id); + + Future create( + String title, + String body, { + List tags = const [], + int? projectId, + String noteType = 'note', + }) => + _api.create(title, body, + tags: tags, projectId: projectId, noteType: noteType); + + Future update( + int id, + String title, + String body, { + List tags = const [], + int? projectId, + bool clearProject = false, + String noteType = 'note', + }) => + _api.update(id, title, body, + tags: tags, + projectId: projectId, + clearProject: clearProject, + noteType: noteType); + + Future delete(int id) => _api.delete(id); +} +``` + +- [ ] **Step 6: Update `lib/providers/notes_provider.dart`** + +```dart +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../data/models/note.dart'; +import 'api_client_provider.dart'; + +final notesProvider = + AsyncNotifierProvider>(NotesNotifier.new); + +class NotesNotifier extends AsyncNotifier> { + @override + Future> build() async { + return ref.watch(notesRepositoryProvider).getAll(); + } + + Future create( + String title, + String body, { + List tags = const [], + int? projectId, + String noteType = 'note', + }) async { + final note = await ref.read(notesRepositoryProvider).create( + title, body, + tags: tags, + projectId: projectId, + noteType: noteType, + ); + state = AsyncData([...state.value ?? [], note]); + return note; + } + + Future updateNote( + int id, + String title, + String body, { + List tags = const [], + int? projectId, + bool clearProject = false, + String noteType = 'note', + }) async { + final updated = await ref.read(notesRepositoryProvider).update( + id, title, body, + tags: tags, + projectId: projectId, + clearProject: clearProject, + noteType: noteType, + ); + state = AsyncData([ + for (final n in state.value ?? []) + if (n.id == id) updated else n, + ]); + return updated; + } + + Future delete(int id) async { + await ref.read(notesRepositoryProvider).delete(id); + state = AsyncData([ + for (final n in state.value ?? []) + if (n.id != id) n, + ]); + } +} + +final noteDetailProvider = + FutureProvider.family((ref, id) async { + return ref.watch(notesRepositoryProvider).getOne(id); +}); +``` + +- [ ] **Step 7: Analyze** + +```bash +flutter analyze lib/data/models/note.dart lib/data/api/notes_api.dart lib/data/repositories/notes_repository.dart lib/providers/notes_provider.dart +``` + +Expected: `No issues found!` + +- [ ] **Step 8: Commit** + +```bash +git add lib/data/models/note.dart lib/data/api/notes_api.dart lib/data/repositories/notes_repository.dart lib/providers/notes_provider.dart test/widget_test.dart +git commit -m "feat(knowledge): add noteType field to Note model and thread through API/repo/provider" +``` + +--- + +## Task 3: KnowledgeItem Model + +**Files:** +- Create: `lib/data/models/knowledge_item.dart` + +- [ ] **Step 1: Create the model** + +Create `lib/data/models/knowledge_item.dart`: + +```dart +/// Unified model for all knowledge types returned by /api/knowledge. +/// noteType is one of: 'note' | 'person' | 'place' | 'list' | 'task' +class KnowledgeItem { + final int id; + final String noteType; + final String title; + final String body; + final List tags; + final int? projectId; + final int? milestoneId; + final int? parentId; + // Task-only fields — null for non-task types + final String? status; + final String? priority; + final String? dueDate; + final DateTime createdAt; + final DateTime updatedAt; + + const KnowledgeItem({ + required this.id, + required this.noteType, + required this.title, + required this.body, + required this.tags, + this.projectId, + this.milestoneId, + this.parentId, + this.status, + this.priority, + this.dueDate, + required this.createdAt, + required this.updatedAt, + }); + + factory KnowledgeItem.fromJson(Map json) => KnowledgeItem( + id: json['id'] as int, + noteType: json['note_type'] as String? ?? 'note', + title: json['title'] as String? ?? '', + body: json['body'] as String? ?? '', + tags: (json['tags'] as List?) + ?.map((e) => e as String) + .toList() ?? + [], + projectId: json['project_id'] as int?, + milestoneId: json['milestone_id'] as int?, + parentId: json['parent_id'] as int?, + status: json['status'] as String?, + priority: json['priority'] as String?, + dueDate: json['due_date'] as String?, + createdAt: DateTime.parse(json['created_at'] as String), + updatedAt: DateTime.parse(json['updated_at'] as String), + ); +} +``` + +- [ ] **Step 2: Add a unit test to `test/widget_test.dart`** + +Append this group to the existing `main()` in `test/widget_test.dart`: + +```dart +// Add this import at the top of the file: +// import 'package:fabled_app/data/models/knowledge_item.dart'; + + group('KnowledgeItem.fromJson', () { + test('parses a task item with task fields', () { + final json = { + 'id': 10, + 'note_type': 'task', + 'title': 'Do the thing', + 'body': '', + 'tags': [], + 'status': 'todo', + 'priority': 'high', + 'due_date': '2025-01-01', + 'created_at': '2024-01-01T00:00:00', + 'updated_at': '2024-01-01T00:00:00', + }; + final item = KnowledgeItem.fromJson(json); + expect(item.noteType, equals('task')); + expect(item.status, equals('todo')); + expect(item.priority, equals('high')); + }); + + test('defaults noteType to note when absent', () { + final json = { + 'id': 11, + 'title': 'A note', + 'body': '', + 'tags': [], + 'created_at': '2024-01-01T00:00:00', + 'updated_at': '2024-01-01T00:00:00', + }; + final item = KnowledgeItem.fromJson(json); + expect(item.noteType, equals('note')); + expect(item.status, isNull); + }); + }); +``` + +Full updated `test/widget_test.dart`: + +```dart +import 'package:fabled_app/data/models/knowledge_item.dart'; +import 'package:fabled_app/data/models/note.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('Note.fromJson', () { + test('parses noteType when present', () { + final json = { + 'id': 1, + 'title': 'Alice', + 'body': '', + 'tags': [], + 'note_type': 'person', + 'created_at': '2024-01-01T00:00:00', + 'updated_at': '2024-01-01T00:00:00', + }; + final note = Note.fromJson(json); + expect(note.noteType, equals('person')); + }); + + test('defaults noteType to note when absent', () { + final json = { + 'id': 2, + 'title': 'My note', + 'body': '', + 'tags': [], + 'created_at': '2024-01-01T00:00:00', + 'updated_at': '2024-01-01T00:00:00', + }; + final note = Note.fromJson(json); + expect(note.noteType, equals('note')); + }); + }); + + group('KnowledgeItem.fromJson', () { + test('parses a task item with task fields', () { + final json = { + 'id': 10, + 'note_type': 'task', + 'title': 'Do the thing', + 'body': '', + 'tags': [], + 'status': 'todo', + 'priority': 'high', + 'due_date': '2025-01-01', + 'created_at': '2024-01-01T00:00:00', + 'updated_at': '2024-01-01T00:00:00', + }; + final item = KnowledgeItem.fromJson(json); + expect(item.noteType, equals('task')); + expect(item.status, equals('todo')); + expect(item.priority, equals('high')); + }); + + test('defaults noteType to note when absent', () { + final json = { + 'id': 11, + 'title': 'A note', + 'body': '', + 'tags': [], + 'created_at': '2024-01-01T00:00:00', + 'updated_at': '2024-01-01T00:00:00', + }; + final item = KnowledgeItem.fromJson(json); + expect(item.noteType, equals('note')); + expect(item.status, isNull); + }); + }); +} +``` + +- [ ] **Step 3: Run tests** + +```bash +flutter test test/widget_test.dart +``` + +Expected: `All tests passed!` + +- [ ] **Step 4: Commit** + +```bash +git add lib/data/models/knowledge_item.dart test/widget_test.dart +git commit -m "feat(knowledge): add KnowledgeItem model with unit tests" +``` + +--- + +## Task 4: KnowledgeApi + KnowledgeRepository + Provider Wiring + +**Files:** +- Create: `lib/data/api/knowledge_api.dart` +- Create: `lib/data/repositories/knowledge_repository.dart` +- Modify: `lib/providers/api_client_provider.dart` + +- [ ] **Step 1: Create `lib/data/api/knowledge_api.dart`** + +```dart +import 'package:dio/dio.dart'; + +import '../models/knowledge_item.dart'; +import 'api_client.dart'; + +class KnowledgeApi { + final Dio _dio; + const KnowledgeApi(this._dio); + + /// Fetch a page of IDs matching the given filters. + /// Returns (ids, total). + Future<(List, int)> fetchIds({ + String? noteType, + List tags = const [], + String sort = 'modified', + String? q, + int limit = 50, + int offset = 0, + }) async { + try { + final params = { + 'limit': limit, + 'offset': offset, + 'sort': sort, + if (noteType != null) 'type': noteType, + if (tags.isNotEmpty) 'tags': tags.join(','), + if (q != null && q.isNotEmpty) 'q': q, + }; + final response = await _dio.get( + '/api/knowledge/ids', + queryParameters: params, + ); + final data = response.data as Map; + final ids = + (data['ids'] as List).map((e) => e as int).toList(); + final total = data['total'] as int; + return (ids, total); + } on DioException catch (e) { + throw dioToApp(e); + } + } + + /// Batch-hydrate up to 100 IDs into full KnowledgeItems. + Future> fetchBatch(List ids) async { + if (ids.isEmpty) return []; + try { + final response = await _dio.get( + '/api/knowledge/batch', + queryParameters: {'ids': ids.join(',')}, + ); + final data = response.data as Map; + return (data['items'] as List) + .map((e) => KnowledgeItem.fromJson(e as Map)) + .toList(); + } on DioException catch (e) { + throw dioToApp(e); + } + } + + /// Per-type counts for tab labels. + Future> fetchCounts({List tags = const []}) async { + try { + final response = await _dio.get( + '/api/knowledge/counts', + queryParameters: { + if (tags.isNotEmpty) 'tags': tags.join(','), + }, + ); + return (response.data as Map) + .map((k, v) => MapEntry(k, (v as num).toInt())); + } on DioException catch (e) { + throw dioToApp(e); + } + } + + /// Tags available for the given type filter. + Future> fetchTags({String? noteType}) async { + try { + final response = await _dio.get( + '/api/knowledge/tags', + queryParameters: { + if (noteType != null) 'type': noteType, + }, + ); + return (response.data['tags'] as List) + .map((e) => e as String) + .toList(); + } on DioException catch (e) { + throw dioToApp(e); + } + } +} +``` + +- [ ] **Step 2: Create `lib/data/repositories/knowledge_repository.dart`** + +```dart +import '../api/knowledge_api.dart'; +import '../models/knowledge_item.dart'; + +class KnowledgeRepository { + final KnowledgeApi _api; + const KnowledgeRepository(this._api); + + Future<(List, int)> fetchIds({ + String? noteType, + List tags = const [], + String sort = 'modified', + String? q, + int limit = 50, + int offset = 0, + }) => + _api.fetchIds( + noteType: noteType, + tags: tags, + sort: sort, + q: q, + limit: limit, + offset: offset, + ); + + Future> fetchBatch(List ids) => + _api.fetchBatch(ids); + + Future> fetchCounts({List tags = const []}) => + _api.fetchCounts(tags: tags); + + Future> fetchTags({String? noteType}) => + _api.fetchTags(noteType: noteType); +} +``` + +- [ ] **Step 3: Add providers to `lib/providers/api_client_provider.dart`** + +Add these imports at the top (after the existing imports): + +```dart +import '../data/api/knowledge_api.dart'; +import '../data/repositories/knowledge_repository.dart'; +``` + +Add these provider declarations at the bottom of the file (before the last closing brace if any, otherwise just append): + +```dart +final knowledgeApiProvider = Provider((ref) { + return KnowledgeApi(ref.watch(dioProvider)); +}); + +final knowledgeRepositoryProvider = Provider((ref) { + return KnowledgeRepository(ref.watch(knowledgeApiProvider)); +}); +``` + +- [ ] **Step 4: Analyze** + +```bash +flutter analyze lib/data/api/knowledge_api.dart lib/data/repositories/knowledge_repository.dart lib/providers/api_client_provider.dart +``` + +Expected: `No issues found!` + +- [ ] **Step 5: Commit** + +```bash +git add lib/data/api/knowledge_api.dart lib/data/repositories/knowledge_repository.dart lib/providers/api_client_provider.dart +git commit -m "feat(knowledge): add KnowledgeApi, KnowledgeRepository, and provider wiring" +``` + +--- + +## Task 5: KnowledgeNotifier + +**Files:** +- Create: `lib/providers/knowledge_provider.dart` + +- [ ] **Step 1: Create `lib/providers/knowledge_provider.dart`** + +```dart +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../data/models/knowledge_item.dart'; +import 'api_client_provider.dart'; + +/// Immutable state for the Knowledge screen's two-tier paginated feed. +class KnowledgeState { + final List ids; + final Map items; + final int totalIds; + final bool isLoadingIds; + final bool isLoadingBatch; + final bool hasMore; + final String? noteType; // null = All + final List activeTags; + final String? searchQuery; + final Map counts; + final List availableTags; + final String? error; + + const KnowledgeState({ + this.ids = const [], + this.items = const {}, + this.totalIds = 0, + this.isLoadingIds = false, + this.isLoadingBatch = false, + this.hasMore = false, + this.noteType, + this.activeTags = const [], + this.searchQuery, + this.counts = const {}, + this.availableTags = const [], + this.error, + }); + + /// Items in server-defined ID order, only those already hydrated. + List get orderedItems => + ids.where(items.containsKey).map((id) => items[id]!).toList(); + + /// IDs that have been fetched but not yet hydrated. + List get unhydratedIds => + ids.where((id) => !items.containsKey(id)).toList(); + + KnowledgeState copyWith({ + List? ids, + Map? items, + int? totalIds, + bool? isLoadingIds, + bool? isLoadingBatch, + bool? hasMore, + Object? noteType = _keep, + List? activeTags, + Object? searchQuery = _keep, + Map? counts, + List? availableTags, + Object? error = _keep, + }) => + KnowledgeState( + ids: ids ?? this.ids, + items: items ?? this.items, + totalIds: totalIds ?? this.totalIds, + isLoadingIds: isLoadingIds ?? this.isLoadingIds, + isLoadingBatch: isLoadingBatch ?? this.isLoadingBatch, + hasMore: hasMore ?? this.hasMore, + noteType: identical(noteType, _keep) ? this.noteType : noteType as String?, + activeTags: activeTags ?? this.activeTags, + searchQuery: identical(searchQuery, _keep) ? this.searchQuery : searchQuery as String?, + counts: counts ?? this.counts, + availableTags: availableTags ?? this.availableTags, + error: identical(error, _keep) ? this.error : error as String?, + ); + + static const _keep = Object(); +} + +class KnowledgeNotifier extends StateNotifier { + KnowledgeNotifier(this._ref) : super(const KnowledgeState()); + + final Ref _ref; + + KnowledgeRepository get _repo => _ref.read(knowledgeRepositoryProvider); + + // ── Filter setters — each resets and re-fetches ────────────────────────── + + Future setTypeFilter(String? noteType) async { + state = KnowledgeState(noteType: noteType, activeTags: state.activeTags); + await _fetchFromScratch(); + } + + Future toggleTag(String tag) async { + final tags = state.activeTags.contains(tag) + ? state.activeTags.where((t) => t != tag).toList() + : [...state.activeTags, tag]; + state = KnowledgeState( + noteType: state.noteType, + activeTags: tags, + searchQuery: state.searchQuery, + ); + await _fetchFromScratch(); + } + + Future setSearch(String? q) async { + final query = (q?.trim().isEmpty ?? true) ? null : q?.trim(); + state = KnowledgeState( + noteType: state.noteType, + activeTags: state.activeTags, + searchQuery: query, + ); + await _fetchFromScratch(); + } + + Future refresh() async { + state = KnowledgeState( + noteType: state.noteType, + activeTags: state.activeTags, + searchQuery: state.searchQuery, + ); + await _fetchFromScratch(); + } + + // ── Scroll-triggered loaders ───────────────────────────────────────────── + + /// Hydrate the next 12 un-hydrated IDs. Call when approaching scroll end. + Future hydrateNext() async { + if (state.isLoadingBatch) return; + final toFetch = state.unhydratedIds.take(12).toList(); + if (toFetch.isEmpty) { + // All fetched IDs are hydrated — try loading more IDs. + if (state.hasMore && !state.isLoadingIds) await _loadMoreIds(); + return; + } + state = state.copyWith(isLoadingBatch: true); + try { + final fetched = await _repo.fetchBatch(toFetch); + final updated = Map.from(state.items); + for (final item in fetched) { + updated[item.id] = item; + } + state = state.copyWith(items: updated, isLoadingBatch: false); + } catch (_) { + state = state.copyWith(isLoadingBatch: false); + } + } + + // ── Private helpers ────────────────────────────────────────────────────── + + Future _fetchFromScratch() async { + state = state.copyWith(isLoadingIds: true, error: null); + try { + final (ids, total) = await _repo.fetchIds( + noteType: state.noteType, + tags: state.activeTags, + q: state.searchQuery, + limit: 50, + offset: 0, + ); + state = state.copyWith( + ids: ids, + items: {}, + totalIds: total, + isLoadingIds: false, + hasMore: ids.length < total, + ); + // Load counts and tags in parallel with the first batch hydration. + await Future.wait([ + hydrateNext(), + _loadCounts(), + _loadTags(), + ]); + } catch (e) { + state = state.copyWith( + isLoadingIds: false, + error: e.toString(), + ); + } + } + + Future _loadMoreIds() async { + if (!state.hasMore || state.isLoadingIds) return; + state = state.copyWith(isLoadingIds: true); + try { + final (newIds, total) = await _repo.fetchIds( + noteType: state.noteType, + tags: state.activeTags, + q: state.searchQuery, + limit: 50, + offset: state.ids.length, + ); + final combined = [...state.ids, ...newIds]; + state = state.copyWith( + ids: combined, + totalIds: total, + isLoadingIds: false, + hasMore: combined.length < total, + ); + } catch (_) { + state = state.copyWith(isLoadingIds: false); + } + } + + Future _loadCounts() async { + try { + final counts = await _repo.fetchCounts(tags: state.activeTags); + state = state.copyWith(counts: counts); + } catch (_) {} + } + + Future _loadTags() async { + try { + final tags = await _repo.fetchTags(noteType: state.noteType); + state = state.copyWith(availableTags: tags); + } catch (_) {} + } +} + +final knowledgeProvider = + StateNotifierProvider( + (ref) => KnowledgeNotifier(ref), +); +``` + +- [ ] **Step 2: Analyze** + +```bash +flutter analyze lib/providers/knowledge_provider.dart +``` + +Expected: `No issues found!` + +- [ ] **Step 3: Commit** + +```bash +git add lib/providers/knowledge_provider.dart +git commit -m "feat(knowledge): add KnowledgeNotifier with two-tier pagination state" +``` + +--- + +## Task 6: KnowledgeItemCard Widget + +**Files:** +- Create: `lib/widgets/knowledge_item_card.dart` + +- [ ] **Step 1: Create `lib/widgets/knowledge_item_card.dart`** + +```dart +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +import '../data/models/knowledge_item.dart'; + +class KnowledgeItemCard extends StatelessWidget { + final KnowledgeItem item; + const KnowledgeItemCard({super.key, required this.item}); + + IconData get _icon => switch (item.noteType) { + 'person' => Icons.person_outlined, + 'place' => Icons.place_outlined, + 'list' => Icons.checklist_outlined, + 'task' => Icons.task_alt_outlined, + _ => Icons.description_outlined, + }; + + Color _statusColor(BuildContext context) { + if (item.noteType != 'task') return Theme.of(context).colorScheme.primary; + return switch (item.status) { + 'done' => Colors.green, + 'in_progress' => Colors.orange, + 'cancelled' => Colors.grey, + _ => Theme.of(context).colorScheme.primary, + }; + } + + String? get _subtitle { + if (item.noteType == 'task') { + if (item.dueDate != null) return 'Due ${item.dueDate}'; + return item.status; + } + if (item.body.trim().isEmpty) return null; + final preview = item.body.trim().replaceAll('\n', ' '); + return preview.length > 120 ? '${preview.substring(0, 120)}…' : preview; + } + + @override + Widget build(BuildContext context) { + return ListTile( + leading: Icon(_icon, color: _statusColor(context)), + title: Text( + item.title.isEmpty ? '(untitled)' : item.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + subtitle: _subtitle != null + ? Text( + _subtitle!, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall, + ) + : null, + trailing: item.tags.isNotEmpty + ? _TagChips(tags: item.tags) + : null, + onTap: () { + if (item.noteType == 'task') { + context.push('/tasks/${item.id}/edit'); + } else { + context.push('/notes/${item.id}'); + } + }, + ); + } +} + +class _TagChips extends StatelessWidget { + final List tags; + const _TagChips({required this.tags}); + + @override + Widget build(BuildContext context) { + final shown = tags.take(2).toList(); + final extra = tags.length - shown.length; + return Wrap( + spacing: 4, + children: [ + for (final t in shown) + Chip( + label: Text(t, style: const TextStyle(fontSize: 10)), + padding: EdgeInsets.zero, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + visualDensity: VisualDensity.compact, + ), + if (extra > 0) + Chip( + label: Text('+$extra', style: const TextStyle(fontSize: 10)), + padding: EdgeInsets.zero, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + visualDensity: VisualDensity.compact, + ), + ], + ); + } +} +``` + +- [ ] **Step 2: Analyze** + +```bash +flutter analyze lib/widgets/knowledge_item_card.dart +``` + +Expected: `No issues found!` + +- [ ] **Step 3: Commit** + +```bash +git add lib/widgets/knowledge_item_card.dart +git commit -m "feat(knowledge): add KnowledgeItemCard widget" +``` + +--- + +## Task 7: KnowledgeScreen + +**Files:** +- Create: `lib/screens/knowledge/knowledge_screen.dart` + +- [ ] **Step 1: Create `lib/screens/knowledge/knowledge_screen.dart`** + +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../providers/knowledge_provider.dart'; +import '../../widgets/knowledge_item_card.dart'; + +// Type tab configuration: (label, noteType filter value) +const _kTabs = [ + (label: 'All', type: null), + (label: 'Notes', type: 'note'), + (label: 'People', type: 'person'), + (label: 'Places', type: 'place'), + (label: 'Lists', type: 'list'), + (label: 'Tasks', type: 'task'), +]; + +class KnowledgeScreen extends ConsumerStatefulWidget { + const KnowledgeScreen({super.key}); + + @override + ConsumerState createState() => _KnowledgeScreenState(); +} + +class _KnowledgeScreenState extends ConsumerState + with SingleTickerProviderStateMixin { + late final TabController _tabController; + late final ScrollController _scrollController; + bool _searchActive = false; + final _searchController = TextEditingController(); + var _debounce = DateTime.now(); + + @override + void initState() { + super.initState(); + _tabController = TabController(length: _kTabs.length, vsync: this); + _scrollController = ScrollController(); + _scrollController.addListener(_onScroll); + // Trigger initial load + WidgetsBinding.instance.addPostFrameCallback((_) { + ref.read(knowledgeProvider.notifier).refresh(); + }); + _tabController.addListener(_onTabChanged); + } + + @override + void dispose() { + _tabController.dispose(); + _scrollController.dispose(); + _searchController.dispose(); + super.dispose(); + } + + void _onTabChanged() { + if (!_tabController.indexIsChanging) return; + final newType = _kTabs[_tabController.index].type; + ref.read(knowledgeProvider.notifier).setTypeFilter(newType); + } + + void _onScroll() { + final pos = _scrollController.position; + if (pos.pixels >= pos.maxScrollExtent - 300) { + ref.read(knowledgeProvider.notifier).hydrateNext(); + } + } + + void _onSearchChanged(String q) { + final now = DateTime.now(); + _debounce = now; + Future.delayed(const Duration(milliseconds: 400), () { + if (_debounce == now && mounted) { + ref.read(knowledgeProvider.notifier).setSearch(q); + } + }); + } + + String _tabLabel(int index, Map counts) { + final tab = _kTabs[index]; + if (tab.type == null) { + final total = counts.values.fold(0, (a, b) => a + b); + return total > 0 ? 'All ($total)' : 'All'; + } + final count = counts[tab.type]; + return count != null && count > 0 ? '${tab.label} ($count)' : tab.label; + } + + @override + Widget build(BuildContext context) { + final state = ref.watch(knowledgeProvider); + + return Scaffold( + appBar: AppBar( + title: _searchActive + ? TextField( + controller: _searchController, + autofocus: true, + decoration: const InputDecoration( + hintText: 'Search knowledge…', + border: InputBorder.none, + ), + onChanged: _onSearchChanged, + ) + : const Text('Knowledge'), + actions: [ + IconButton( + icon: Icon(_searchActive ? Icons.close : Icons.search), + onPressed: () { + setState(() => _searchActive = !_searchActive); + if (!_searchActive) { + _searchController.clear(); + ref.read(knowledgeProvider.notifier).setSearch(null); + } + }, + ), + ], + bottom: TabBar( + controller: _tabController, + isScrollable: true, + tabAlignment: TabAlignment.start, + tabs: List.generate( + _kTabs.length, + (i) => Tab(text: _tabLabel(i, state.counts)), + ), + ), + ), + body: Column( + children: [ + // Tag filter chips + if (state.availableTags.isNotEmpty) + SizedBox( + height: 48, + child: ListView.separated( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + itemCount: state.availableTags.length, + separatorBuilder: (_, __) => const SizedBox(width: 6), + itemBuilder: (_, i) { + final tag = state.availableTags[i]; + final active = state.activeTags.contains(tag); + return FilterChip( + label: Text(tag), + selected: active, + onSelected: (_) => + ref.read(knowledgeProvider.notifier).toggleTag(tag), + visualDensity: VisualDensity.compact, + ); + }, + ), + ), + // Main list + Expanded( + child: _buildList(state), + ), + ], + ), + floatingActionButton: FloatingActionButton( + onPressed: () => _onFabTapped(context), + tooltip: 'New', + child: const Icon(Icons.edit_outlined), + ), + ); + } + + Widget _buildList(KnowledgeState state) { + if (state.isLoadingIds && state.ids.isEmpty) { + return const Center(child: CircularProgressIndicator()); + } + if (state.error != null && state.ids.isEmpty) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text('Failed to load', style: Theme.of(context).textTheme.bodyLarge), + const SizedBox(height: 8), + TextButton( + onPressed: () => ref.read(knowledgeProvider.notifier).refresh(), + child: const Text('Retry'), + ), + ], + ), + ); + } + final items = state.orderedItems; + if (items.isEmpty && !state.isLoadingIds && !state.isLoadingBatch) { + return Center( + child: Text( + 'No ${_kTabs[_tabController.index].label.toLowerCase()} yet.', + style: Theme.of(context).textTheme.bodyLarge, + ), + ); + } + return RefreshIndicator( + onRefresh: () => ref.read(knowledgeProvider.notifier).refresh(), + child: ListView.separated( + controller: _scrollController, + itemCount: items.length + (state.isLoadingBatch ? 1 : 0), + separatorBuilder: (_, __) => const Divider(height: 1), + itemBuilder: (_, i) { + if (i >= items.length) { + return const Padding( + padding: EdgeInsets.all(16), + child: Center(child: CircularProgressIndicator()), + ); + } + return KnowledgeItemCard(item: items[i]); + }, + ), + ); + } + + void _onFabTapped(BuildContext context) { + final currentType = _kTabs[_tabController.index].type; + if (currentType == 'task') { + context.push('/tasks/new'); + return; + } + _showTypePicker(context); + } + + void _showTypePicker(BuildContext context) { + showModalBottomSheet( + context: context, + builder: (sheetContext) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _TypePickerRow( + icon: Icons.description_outlined, + label: 'Note', + description: 'General note or document', + onTap: () { + Navigator.pop(sheetContext); + context.push('/notes/new', extra: {'noteType': 'note'}); + }, + ), + _TypePickerRow( + icon: Icons.person_outlined, + label: 'Person', + description: 'Contact, colleague, or reference person', + onTap: () { + Navigator.pop(sheetContext); + context.push('/notes/new', extra: {'noteType': 'person'}); + }, + ), + _TypePickerRow( + icon: Icons.place_outlined, + label: 'Place', + description: 'Location, venue, or place of interest', + onTap: () { + Navigator.pop(sheetContext); + context.push('/notes/new', extra: {'noteType': 'place'}); + }, + ), + _TypePickerRow( + icon: Icons.checklist_outlined, + label: 'List', + description: 'Checklist or structured list', + onTap: () { + Navigator.pop(sheetContext); + context.push('/notes/new', extra: {'noteType': 'list'}); + }, + ), + ], + ), + ), + ); + } +} + +class _TypePickerRow extends StatelessWidget { + final IconData icon; + final String label; + final String description; + final VoidCallback onTap; + + const _TypePickerRow({ + required this.icon, + required this.label, + required this.description, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return ListTile( + leading: Icon(icon), + title: Text(label), + subtitle: Text(description), + onTap: onTap, + ); + } +} +``` + +- [ ] **Step 2: Analyze** + +```bash +flutter analyze lib/screens/knowledge/knowledge_screen.dart +``` + +Expected: `No issues found!` + +- [ ] **Step 3: Commit** + +```bash +git add lib/screens/knowledge/knowledge_screen.dart +git commit -m "feat(knowledge): add KnowledgeScreen with two-tier feed, tabs, search, and tag filters" +``` + +--- + +## Task 8: NoteEditScreen — noteType Parameter + +**Files:** +- Modify: `lib/screens/notes/note_edit_screen.dart` + +The screen needs to: +1. Accept an optional `noteType` constructor parameter for new notes +2. Load `noteType` from the existing note when editing +3. Show a type badge chip in the app bar +4. Pass `noteType` to the provider on save + +- [ ] **Step 1: Update `lib/screens/notes/note_edit_screen.dart`** + +Change the class declaration and state to: + +```dart +class NoteEditScreen extends ConsumerStatefulWidget { + final int? noteId; + final String? noteType; // passed when creating a typed note from KnowledgeScreen + const NoteEditScreen({super.key, this.noteId, this.noteType}); + + @override + ConsumerState createState() => _NoteEditScreenState(); +} + +class _NoteEditScreenState extends ConsumerState { + final _titleController = TextEditingController(); + final _contentController = TextEditingController(); + final _tagController = TextEditingController(); + List _tags = []; + int? _projectId; + bool _preview = false; + bool _saving = false; + late String _noteType; // resolved in initState + + late final Future _initFuture; + + @override + void initState() { + super.initState(); + _noteType = widget.noteType ?? 'note'; + _initFuture = widget.noteId != null ? _loadExisting() : Future.value(); + } +``` + +Update `_loadExisting` to also load `noteType`: + +```dart + Future _loadExisting() async { + final note = + await ref.read(notesRepositoryProvider).getOne(widget.noteId!); + _titleController.text = note.title; + _contentController.text = note.body; + _tags = List.from(note.tags); + _projectId = note.projectId; + _noteType = note.noteType; + } +``` + +Update `_save` to pass `_noteType`: + +```dart + Future _save() async { + final title = _titleController.text.trim(); + final body = _contentController.text; + if (title.isEmpty) { + ScaffoldMessenger.of(context) + .showSnackBar(const SnackBar(content: Text('Title is required.'))); + return; + } + setState(() => _saving = true); + try { + if (widget.noteId == null) { + await ref.read(notesProvider.notifier).create( + title, + body, + tags: _tags, + projectId: _projectId, + noteType: _noteType, + ); + if (mounted) context.pop(); + } else { + await ref.read(notesProvider.notifier).updateNote( + widget.noteId!, + title, + body, + tags: _tags, + projectId: _projectId, + clearProject: _projectId == null, + noteType: _noteType, + ); + if (mounted) context.pop(); + } + } on AppException catch (e) { + if (mounted) { + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text(e.message))); + } + } finally { + if (mounted) setState(() => _saving = false); + } + } +``` + +Update `AppBar.title` to show the type badge: + +```dart + appBar: AppBar( + title: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text(widget.noteId == null ? 'New ${_typeName(_noteType)}' : 'Edit ${_typeName(_noteType)}'), + const SizedBox(width: 8), + if (_noteType != 'note') + Chip( + label: Text(_typeName(_noteType), + style: const TextStyle(fontSize: 11)), + padding: EdgeInsets.zero, + visualDensity: VisualDensity.compact, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + ], + ), +``` + +Add the helper at the bottom of the class (outside the State class, as a top-level function or inside the file): + +```dart +String _typeName(String type) => switch (type) { + 'person' => 'Person', + 'place' => 'Place', + 'list' => 'List', + 'task' => 'Task', + _ => 'Note', + }; +``` + +Also update the GoRouter entry for `Routes.noteNew` in `app.dart` (done in Task 11) to read `extra` and pass it to `NoteEditScreen`. For now the screen compiles with the constructor as-is. + +- [ ] **Step 2: Analyze** + +```bash +flutter analyze lib/screens/notes/note_edit_screen.dart +``` + +Expected: `No issues found!` + +- [ ] **Step 3: Commit** + +```bash +git add lib/screens/notes/note_edit_screen.dart +git commit -m "feat(knowledge): thread noteType through NoteEditScreen for typed note creation" +``` + +--- + +## Task 9: Projects Sort + ProjectsScreen + ProjectEditScreen + +**Files:** +- Modify: `lib/data/api/projects_api.dart` +- Modify: `lib/data/repositories/projects_repository.dart` +- Modify: `lib/providers/projects_provider.dart` +- Create: `lib/screens/projects/projects_screen.dart` +- Create: `lib/screens/projects/project_edit_screen.dart` + +- [ ] **Step 1: Add sort params to `lib/data/api/projects_api.dart`** + +Update the `getAll` method: + +```dart + Future> getAll({ + String? status, + String sort = 'updated_at', + String order = 'desc', + }) async { + try { + final response = await _dio.get( + '/api/projects', + queryParameters: { + if (status != null) 'status': status, + 'sort': sort, + 'order': order, + }, + ); + final data = response.data as Map; + final list = data['projects'] as List; + return list + .map((e) => Project.fromJson(e as Map)) + .toList(); + } on DioException catch (e) { + throw dioToApp(e); + } + } +``` + +- [ ] **Step 2: Update `lib/data/repositories/projects_repository.dart`** + +```dart +import '../api/projects_api.dart'; +import '../models/project.dart'; + +class ProjectsRepository { + final ProjectsApi _api; + const ProjectsRepository(this._api); + + Future> getAll({ + String? status, + String sort = 'updated_at', + String order = 'desc', + }) => + _api.getAll(status: status, sort: sort, order: order); + + Future getOne(int id) => _api.getOne(id); + + Future create({ + required String title, + String? description, + String? goal, + String? color, + }) => + _api.create( + title: title, description: description, goal: goal, color: color); + + Future update(int id, Map fields) => + _api.update(id, fields); + + Future delete(int id) => _api.delete(id); +} +``` + +- [ ] **Step 3: Update `lib/providers/projects_provider.dart`** + +Update `build()` to pass sort params: + +```dart + @override + Future> build() async { + return ref.watch(projectsRepositoryProvider).getAll( + sort: 'updated_at', + order: 'desc', + ); + } +``` + +- [ ] **Step 4: Create `lib/screens/projects/projects_screen.dart`** + +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../data/models/project.dart'; +import '../../providers/projects_provider.dart'; + +class ProjectsScreen extends ConsumerWidget { + const ProjectsScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final projectsAsync = ref.watch(projectsProvider); + return Scaffold( + appBar: AppBar(title: const Text('Projects')), + body: projectsAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => Center(child: Text('Error: $e')), + data: (projects) => projects.isEmpty + ? const Center(child: Text('No projects yet.')) + : RefreshIndicator( + onRefresh: () async => ref.invalidate(projectsProvider), + child: ListView.separated( + itemCount: projects.length, + separatorBuilder: (_, __) => const Divider(height: 1), + itemBuilder: (_, i) => _ProjectCard(project: projects[i]), + ), + ), + ), + floatingActionButton: FloatingActionButton( + onPressed: () => context.push('/projects/new'), + tooltip: 'New project', + child: const Icon(Icons.add), + ), + ); + } +} + +class _ProjectCard extends StatelessWidget { + final Project project; + const _ProjectCard({required this.project}); + + Color _statusColor(BuildContext context) => switch (project.status) { + 'completed' => Colors.blue, + 'archived' => Colors.grey, + _ => Theme.of(context).colorScheme.primary, + }; + + @override + Widget build(BuildContext context) { + return ListTile( + title: Text(project.title), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (project.description?.isNotEmpty == true) + Text( + project.description!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (project.goal?.isNotEmpty == true) + Text( + project.goal!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context) + .textTheme + .bodySmall + ?.copyWith(fontStyle: FontStyle.italic), + ), + ], + ), + trailing: Chip( + label: Text( + project.status, + style: TextStyle( + fontSize: 11, + color: _statusColor(context), + ), + ), + padding: EdgeInsets.zero, + visualDensity: VisualDensity.compact, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + onTap: () => context.push('/projects/${project.id}/tasks'), + ); + } +} +``` + +- [ ] **Step 5: Create `lib/screens/projects/project_edit_screen.dart`** + +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../core/exceptions.dart'; +import '../../providers/projects_provider.dart'; + +class ProjectEditScreen extends ConsumerStatefulWidget { + final int? projectId; + const ProjectEditScreen({super.key, this.projectId}); + + @override + ConsumerState createState() => _ProjectEditScreenState(); +} + +class _ProjectEditScreenState extends ConsumerState { + final _titleController = TextEditingController(); + final _descController = TextEditingController(); + final _goalController = TextEditingController(); + String _status = 'active'; + bool _saving = false; + late final Future _initFuture; + + @override + void initState() { + super.initState(); + _initFuture = + widget.projectId != null ? _loadExisting() : Future.value(); + } + + @override + void dispose() { + _titleController.dispose(); + _descController.dispose(); + _goalController.dispose(); + super.dispose(); + } + + Future _loadExisting() async { + final projects = ref.read(projectsProvider).value ?? []; + final project = + projects.where((p) => p.id == widget.projectId).firstOrNull; + if (project != null) { + _titleController.text = project.title; + _descController.text = project.description ?? ''; + _goalController.text = project.goal ?? ''; + _status = project.status; + } + } + + Future _save() async { + final title = _titleController.text.trim(); + if (title.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Title is required.'))); + return; + } + setState(() => _saving = true); + try { + if (widget.projectId == null) { + await ref.read(projectsProvider.notifier).create( + title: title, + description: _descController.text.trim().isEmpty + ? null + : _descController.text.trim(), + goal: _goalController.text.trim().isEmpty + ? null + : _goalController.text.trim(), + ); + } else { + await ref.read(projectsProvider.notifier).updateProject( + widget.projectId!, + { + 'title': title, + 'description': _descController.text.trim(), + 'goal': _goalController.text.trim(), + 'status': _status, + }, + ); + } + if (mounted) context.pop(); + } on AppException catch (e) { + if (mounted) { + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text(e.message))); + } + } finally { + if (mounted) setState(() => _saving = false); + } + } + + @override + Widget build(BuildContext context) { + return FutureBuilder( + future: _initFuture, + builder: (context, snapshot) => Scaffold( + appBar: AppBar( + title: Text( + widget.projectId == null ? 'New Project' : 'Edit Project'), + actions: [ + IconButton( + icon: _saving + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.check), + onPressed: _saving ? null : _save, + ), + ], + ), + body: snapshot.connectionState == ConnectionState.waiting + ? const Center(child: CircularProgressIndicator()) + : ListView( + padding: const EdgeInsets.all(16), + children: [ + TextField( + controller: _titleController, + decoration: const InputDecoration( + labelText: 'Title *', border: OutlineInputBorder()), + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + TextField( + controller: _descController, + decoration: const InputDecoration( + labelText: 'Description', + border: OutlineInputBorder()), + maxLines: 3, + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + TextField( + controller: _goalController, + decoration: const InputDecoration( + labelText: 'Goal', border: OutlineInputBorder()), + maxLines: 2, + textInputAction: TextInputAction.done, + ), + if (widget.projectId != null) ...[ + const SizedBox(height: 12), + DropdownButtonFormField( + value: _status, + decoration: const InputDecoration( + labelText: 'Status', + border: OutlineInputBorder()), + items: const [ + DropdownMenuItem( + value: 'active', child: Text('Active')), + DropdownMenuItem( + value: 'completed', child: Text('Completed')), + DropdownMenuItem( + value: 'archived', child: Text('Archived')), + ], + onChanged: (v) => setState(() => _status = v!), + ), + ], + ], + ), + ), + ); + } +} +``` + +- [ ] **Step 6: Analyze** + +```bash +flutter analyze lib/data/api/projects_api.dart lib/data/repositories/projects_repository.dart lib/providers/projects_provider.dart lib/screens/projects/ +``` + +Expected: `No issues found!` + +- [ ] **Step 7: Commit** + +```bash +git add lib/data/api/projects_api.dart lib/data/repositories/projects_repository.dart lib/providers/projects_provider.dart lib/screens/projects/ +git commit -m "feat(knowledge): add ProjectsScreen and ProjectEditScreen, sort projects by updated_at" +``` + +--- + +## Task 10: ProjectTasksScreen — Edit Button + +**Files:** +- Modify: `lib/screens/library/project_tasks_screen.dart` + +- [ ] **Step 1: Add edit button to AppBar** + +In `project_tasks_screen.dart`, find the `AppBar` widget inside the `build` method (around line 71) and add `actions`: + +```dart + appBar: AppBar( + title: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + project?.title ?? 'Project', + style: Theme.of(context).textTheme.titleLarge, + ), + if (project?.description?.isNotEmpty == true) + Text( + project!.description!, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + actions: [ + IconButton( + icon: const Icon(Icons.edit_outlined), + tooltip: 'Edit project', + onPressed: () => + context.push('/projects/${widget.projectId}/edit'), + ), + ], + ), +``` + +- [ ] **Step 2: Analyze** + +```bash +flutter analyze lib/screens/library/project_tasks_screen.dart +``` + +Expected: `No issues found!` + +- [ ] **Step 3: Commit** + +```bash +git add lib/screens/library/project_tasks_screen.dart +git commit -m "feat(knowledge): add edit button to ProjectTasksScreen app bar" +``` + +--- + +## Task 11: Shell Navigation — Wire 4 Tabs & Retire LibraryScreen + +**Files:** +- Modify: `lib/app.dart` + +This is the final wiring task. It replaces the Library shell route with Knowledge and Projects, updates the bottom nav to 4 tabs, and passes `noteType` from `GoRouter.extra` to `NoteEditScreen`. + +- [ ] **Step 1: Update imports in `lib/app.dart`** + +Add these imports (remove the `library_screen.dart` import): + +```dart +import 'screens/knowledge/knowledge_screen.dart'; +import 'screens/projects/projects_screen.dart'; +import 'screens/projects/project_edit_screen.dart'; +``` + +Remove: +```dart +import 'screens/library/library_screen.dart'; +``` + +- [ ] **Step 2: Update the GoRouter routes** + +Replace the `ShellRoute` block and the `Routes.projectTasks` route with: + +```dart + GoRoute( + path: Routes.projectTasks, + builder: (_, state) => ProjectTasksScreen( + projectId: int.parse(state.pathParameters['id']!), + ), + ), + GoRoute( + path: '/projects/new', + builder: (_, _) => const ProjectEditScreen(), + ), + GoRoute( + path: Routes.projectEdit, + builder: (_, state) => ProjectEditScreen( + projectId: int.parse(state.pathParameters['id']!), + ), + ), + ShellRoute( + builder: (context, state, child) => _Shell(child: child), + routes: [ + GoRoute( + path: Routes.briefing, + builder: (_, _) => const BriefingScreen(), + ), + GoRoute( + path: Routes.knowledge, + builder: (_, _) => const KnowledgeScreen(), + ), + GoRoute( + path: Routes.conversations, + builder: (_, _) => const ConversationsTabScreen(), + ), + GoRoute( + path: Routes.projects, + builder: (_, _) => const ProjectsScreen(), + ), + ], + ), +``` + +Update the `Routes.noteNew` route to pass `noteType` from `extra`: + +```dart + GoRoute( + path: Routes.noteNew, + builder: (_, state) { + final extra = state.extra as Map?; + return NoteEditScreen(noteType: extra?['noteType'] as String?); + }, + ), +``` + +- [ ] **Step 3: Update `_Shell._tabs` and navigation bars** + +Replace the `_tabs` list and all four `NavigationDestination` / `NavigationRailDestination` entries: + +```dart + static const _tabs = [ + Routes.briefing, + Routes.knowledge, + Routes.conversations, + Routes.projects, + ]; +``` + +Bottom `NavigationBar` destinations: + +```dart + destinations: const [ + NavigationDestination( + icon: Icon(Icons.wb_sunny_outlined), + selectedIcon: Icon(Icons.wb_sunny), + label: 'Briefing', + ), + NavigationDestination( + icon: Icon(Icons.menu_book_outlined), + selectedIcon: Icon(Icons.menu_book), + label: 'Knowledge', + ), + NavigationDestination( + icon: Icon(Icons.chat_bubble_outline), + selectedIcon: Icon(Icons.chat_bubble), + label: 'Chat', + ), + NavigationDestination( + icon: Icon(Icons.folder_outlined), + selectedIcon: Icon(Icons.folder), + label: 'Projects', + ), + ], +``` + +Wide-screen `NavigationRail` destinations: + +```dart + destinations: const [ + NavigationRailDestination( + icon: Icon(Icons.wb_sunny_outlined), + selectedIcon: Icon(Icons.wb_sunny), + label: Text('Briefing'), + ), + NavigationRailDestination( + icon: Icon(Icons.menu_book_outlined), + selectedIcon: Icon(Icons.menu_book), + label: Text('Knowledge'), + ), + NavigationRailDestination( + icon: Icon(Icons.chat_bubble_outline), + selectedIcon: Icon(Icons.chat_bubble), + label: Text('Chat'), + ), + NavigationRailDestination( + icon: Icon(Icons.folder_outlined), + selectedIcon: Icon(Icons.folder), + label: Text('Projects'), + ), + ], +``` + +Update `_hintForLocation`: + +```dart + String _hintForLocation(String location) { + if (location.startsWith(Routes.knowledge)) return 'Capture a note…'; + if (location.startsWith(Routes.projects)) return 'Capture a note…'; + if (location.startsWith(Routes.conversations)) return 'Ask Fabled…'; + return 'Capture a note…'; + } +``` + +- [ ] **Step 4: Analyze** + +```bash +flutter analyze lib/app.dart +``` + +Expected: `No issues found!` + +- [ ] **Step 5: Full build** + +```bash +flutter build apk --debug +``` + +Expected: Build succeeds with `Built build/app/outputs/flutter-apk/app-debug.apk` + +- [ ] **Step 6: Delete retired files** + +```bash +rm lib/screens/library/library_screen.dart +rm lib/widgets/library_item_card.dart +``` + +- [ ] **Step 7: Analyze again (confirms no remaining references to deleted files)** + +```bash +flutter analyze +``` + +Expected: `No issues found!` + +- [ ] **Step 8: Final build** + +```bash +flutter build apk --debug +``` + +Expected: `Built build/app/outputs/flutter-apk/app-debug.apk` + +- [ ] **Step 9: Commit** + +```bash +git add -A +git commit -m "feat(knowledge): wire 4-tab shell, retire LibraryScreen, complete Knowledge View feature" +``` diff --git a/lib/app.dart b/lib/app.dart index 2164a41..009d840 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -17,11 +17,13 @@ import 'providers/update_provider.dart'; import 'providers/tasks_provider.dart'; import 'screens/auth/login_screen.dart'; import 'screens/briefing/briefing_screen.dart'; +import 'screens/knowledge/knowledge_screen.dart'; import 'screens/library/project_tasks_screen.dart'; import 'screens/chat/chat_screen.dart'; import 'screens/chat/conversations_tab_screen.dart'; -import 'screens/library/library_screen.dart'; import 'screens/notes/note_detail_screen.dart'; +import 'screens/projects/project_edit_screen.dart'; +import 'screens/projects/projects_screen.dart'; import 'screens/notes/note_edit_screen.dart'; import 'screens/settings/settings_screen.dart'; import 'screens/setup/setup_screen.dart'; @@ -82,7 +84,10 @@ final routerProvider = Provider((ref) { ), GoRoute( path: Routes.noteNew, - builder: (_, _) => const NoteEditScreen(), + builder: (_, state) { + final extra = state.extra as Map?; + return NoteEditScreen(noteType: extra?['noteType'] as String?); + }, ), GoRoute( path: Routes.noteDetail, @@ -116,6 +121,16 @@ final routerProvider = Provider((ref) { 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']!), + ), + ), GoRoute( path: Routes.chat, builder: (_, state) => ChatScreen( @@ -130,13 +145,17 @@ final routerProvider = Provider((ref) { builder: (_, _) => const BriefingScreen(), ), GoRoute( - path: Routes.library, - builder: (_, _) => const LibraryScreen(), + path: Routes.knowledge, + builder: (_, _) => const KnowledgeScreen(), ), GoRoute( path: Routes.conversations, builder: (_, _) => const ConversationsTabScreen(), ), + GoRoute( + path: Routes.projects, + builder: (_, _) => const ProjectsScreen(), + ), ], ), ], @@ -154,8 +173,9 @@ class _Shell extends ConsumerStatefulWidget { class _ShellState extends ConsumerState<_Shell> { static const _tabs = [ Routes.briefing, - Routes.library, + Routes.knowledge, Routes.conversations, + Routes.projects, ]; @override @@ -290,15 +310,20 @@ class _ShellState extends ConsumerState<_Shell> { label: Text('Briefing'), ), NavigationRailDestination( - icon: Icon(Icons.library_books_outlined), - selectedIcon: Icon(Icons.library_books), - label: Text('Library'), + 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'), + ), ], ), const VerticalDivider(width: 1), @@ -335,15 +360,20 @@ class _ShellState extends ConsumerState<_Shell> { label: 'Briefing', ), NavigationDestination( - icon: Icon(Icons.library_books_outlined), - selectedIcon: Icon(Icons.library_books), - label: 'Library', + 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', + ), ], ), ); @@ -411,8 +441,8 @@ class _QuickCaptureBarState extends ConsumerState<_QuickCaptureBar> { } String _hintForLocation(String location) { - if (location.startsWith(Routes.library) && - location.contains('tasks')) { return 'Add a task…'; } + 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…'; } diff --git a/lib/screens/library/library_screen.dart b/lib/screens/library/library_screen.dart deleted file mode 100644 index e39a216..0000000 --- a/lib/screens/library/library_screen.dart +++ /dev/null @@ -1,294 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:go_router/go_router.dart'; - -import '../../core/constants.dart'; -import '../../data/models/note.dart'; -import '../../data/models/task.dart'; -import '../../providers/notes_provider.dart'; -import '../../providers/projects_provider.dart'; -import '../../providers/tasks_provider.dart'; -import '../../widgets/library_item_card.dart'; - -enum _LibraryFilter { all, notes, tasks, projects } - -enum _TaskStatusFilter { all, todo, inProgress, done } - -class LibraryScreen extends ConsumerStatefulWidget { - const LibraryScreen({super.key}); - - @override - ConsumerState createState() => _LibraryScreenState(); -} - -class _LibraryScreenState extends ConsumerState { - _LibraryFilter _filter = _LibraryFilter.all; - _TaskStatusFilter _taskStatus = _TaskStatusFilter.all; - bool _searchActive = false; - String _searchQuery = ''; - final _searchController = TextEditingController(); - - @override - void dispose() { - _searchController.dispose(); - super.dispose(); - } - - bool _matchesSearch(String text) { - if (_searchQuery.isEmpty) return true; - return text.toLowerCase().contains(_searchQuery.toLowerCase()); - } - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final notesAsync = ref.watch(notesProvider); - final tasksAsync = ref.watch(tasksProvider); - final projectsAsync = ref.watch(projectsProvider); - - return Scaffold( - appBar: AppBar( - title: _searchActive - ? TextField( - controller: _searchController, - autofocus: true, - decoration: const InputDecoration( - hintText: 'Search…', - border: InputBorder.none, - isDense: true, - ), - onChanged: (q) => setState(() => _searchQuery = q), - ) - : Text('Library', style: theme.textTheme.titleLarge), - actions: [ - IconButton( - icon: Icon(_searchActive ? Icons.close : Icons.search), - onPressed: () => setState(() { - _searchActive = !_searchActive; - if (!_searchActive) { - _searchQuery = ''; - _searchController.clear(); - } - }), - ), - ], - ), - body: Column( - children: [ - // ── Filter pills ────────────────────────────────────────────────── - SingleChildScrollView( - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.fromLTRB(12, 8, 12, 4), - child: Row( - children: _LibraryFilter.values.map((f) { - final label = switch (f) { - _LibraryFilter.all => 'All', - _LibraryFilter.notes => 'Notes', - _LibraryFilter.tasks => 'Tasks', - _LibraryFilter.projects => 'Projects', - }; - final selected = _filter == f; - return Padding( - padding: const EdgeInsets.only(right: 8), - child: FilterChip( - label: Text(label), - selected: selected, - onSelected: (_) => setState(() { - _filter = f; - _taskStatus = _TaskStatusFilter.all; - }), - selectedColor: - theme.colorScheme.primary.withValues(alpha: 0.18), - checkmarkColor: theme.colorScheme.primary, - ), - ); - }).toList(), - ), - ), - - // ── Task status sub-filter (Tasks pill only) ─────────────────── - if (_filter == _LibraryFilter.tasks) - SingleChildScrollView( - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.fromLTRB(12, 0, 12, 4), - child: Row( - children: _TaskStatusFilter.values.map((s) { - final label = switch (s) { - _TaskStatusFilter.all => 'All', - _TaskStatusFilter.todo => 'To Do', - _TaskStatusFilter.inProgress => 'In Progress', - _TaskStatusFilter.done => 'Done', - }; - return Padding( - padding: const EdgeInsets.only(right: 8), - child: ChoiceChip( - label: Text(label), - selected: _taskStatus == s, - onSelected: (_) => setState(() => _taskStatus = s), - selectedColor: - theme.colorScheme.secondary.withValues(alpha: 0.15), - ), - ); - }).toList(), - ), - ), - - const Divider(height: 1), - - // ── Content ─────────────────────────────────────────────────────── - Expanded( - child: switch (_filter) { - _LibraryFilter.notes => _buildNotesList(notesAsync), - _LibraryFilter.tasks => _buildTasksList(tasksAsync), - _LibraryFilter.projects => _buildProjectsList(projectsAsync), - _LibraryFilter.all => _buildAllList(notesAsync, tasksAsync), - }, - ), - ], - ), - floatingActionButton: FloatingActionButton( - onPressed: () => _showCreateSheet(context), - child: const Icon(Icons.add), - ), - ); - } - - Widget _buildNotesList(AsyncValue> notesAsync) { - return notesAsync.when( - loading: () => const Center(child: CircularProgressIndicator()), - error: (e, _) => Center(child: Text('Error: $e')), - data: (notes) { - final filtered = notes - .where((n) => _matchesSearch(n.title) || _matchesSearch(n.body)) - .toList(); - if (filtered.isEmpty) { - return const Center(child: Text('No notes found')); - } - return RefreshIndicator( - onRefresh: () async => ref.invalidate(notesProvider), - child: ListView.builder( - itemCount: filtered.length, - itemBuilder: (_, i) => NoteLibraryCard(note: filtered[i]), - ), - ); - }, - ); - } - - Widget _buildTasksList(AsyncValue> tasksAsync) { - return tasksAsync.when( - loading: () => const Center(child: CircularProgressIndicator()), - error: (e, _) => Center(child: Text('Error: $e')), - data: (tasks) { - var filtered = tasks.where((t) => _matchesSearch(t.title)).toList(); - if (_taskStatus != _TaskStatusFilter.all) { - final status = switch (_taskStatus) { - _TaskStatusFilter.todo => TaskStatus.todo, - _TaskStatusFilter.inProgress => TaskStatus.inProgress, - _TaskStatusFilter.done => TaskStatus.done, - _ => TaskStatus.todo, - }; - filtered = filtered.where((t) => t.status == status).toList(); - } - if (filtered.isEmpty) { - return const Center(child: Text('No tasks found')); - } - return RefreshIndicator( - onRefresh: () async => ref.invalidate(tasksProvider), - child: ListView.builder( - itemCount: filtered.length, - itemBuilder: (_, i) => TaskLibraryCard(task: filtered[i]), - ), - ); - }, - ); - } - - Widget _buildProjectsList(AsyncValue> projectsAsync) { - return projectsAsync.when( - loading: () => const Center(child: CircularProgressIndicator()), - error: (e, _) => Center(child: Text('Error: $e')), - data: (projects) { - if (projects.isEmpty) { - return const Center(child: Text('No projects')); - } - return RefreshIndicator( - onRefresh: () async => ref.invalidate(projectsProvider), - child: ListView.builder( - itemCount: projects.length, - itemBuilder: (_, i) => - ProjectLibraryCard(project: projects[i]), - ), - ); - }, - ); - } - - Widget _buildAllList( - AsyncValue> notesAsync, - AsyncValue> tasksAsync, - ) { - final notes = notesAsync.value ?? []; - final tasks = tasksAsync.value ?? []; - - // Merge and sort by updatedAt desc - final items = <(DateTime, Widget)>[]; - for (final n in notes) { - if (_matchesSearch(n.title) || _matchesSearch(n.body)) { - items.add((n.updatedAt, NoteLibraryCard(note: n))); - } - } - for (final t in tasks) { - if (_matchesSearch(t.title)) { - items.add((t.updatedAt, TaskLibraryCard(task: t))); - } - } - items.sort((a, b) => b.$1.compareTo(a.$1)); - - if (notesAsync.isLoading || tasksAsync.isLoading) { - return const Center(child: CircularProgressIndicator()); - } - if (items.isEmpty) { - return const Center(child: Text('Nothing here yet')); - } - return RefreshIndicator( - onRefresh: () async { - ref.invalidate(notesProvider); - ref.invalidate(tasksProvider); - }, - child: ListView.builder( - itemCount: items.length, - itemBuilder: (_, i) => items[i].$2, - ), - ); - } - - void _showCreateSheet(BuildContext context) { - showModalBottomSheet( - context: context, - builder: (_) => SafeArea( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - ListTile( - leading: const Icon(Icons.article_outlined), - title: const Text('New note'), - onTap: () { - Navigator.pop(context); - context.push(Routes.noteNew); - }, - ), - ListTile( - leading: const Icon(Icons.check_box_outlined), - title: const Text('New task'), - onTap: () { - Navigator.pop(context); - context.push(Routes.taskNew); - }, - ), - ], - ), - ), - ); - } -} diff --git a/lib/widgets/library_item_card.dart b/lib/widgets/library_item_card.dart deleted file mode 100644 index ef0a970..0000000 --- a/lib/widgets/library_item_card.dart +++ /dev/null @@ -1,272 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:go_router/go_router.dart'; - -import '../core/constants.dart'; -import '../data/models/note.dart'; -import '../data/models/project.dart'; -import '../data/models/task.dart'; -import '../providers/tasks_provider.dart'; - -// ── Note card ──────────────────────────────────────────────────────────────── - -class NoteLibraryCard extends StatelessWidget { - final Note note; - const NoteLibraryCard({super.key, required this.note}); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - return Card( - margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), - child: InkWell( - onTap: () => context - .push(Routes.noteDetail.replaceFirst(':id', '${note.id}')), - borderRadius: BorderRadius.circular(14), - child: Padding( - padding: const EdgeInsets.fromLTRB(14, 12, 14, 12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon(Icons.article_outlined, - size: 15, color: theme.colorScheme.onSurfaceVariant), - const SizedBox(width: 6), - Expanded( - child: Text( - note.title.isNotEmpty ? note.title : 'Untitled', - style: theme.textTheme.titleSmall, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - Text( - _relativeTime(note.updatedAt), - style: theme.textTheme.labelSmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant), - ), - ], - ), - if (note.body.isNotEmpty) ...[ - const SizedBox(height: 4), - Text( - note.body.replaceAll('\n', ' '), - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ], - if (note.tags.isNotEmpty) ...[ - const SizedBox(height: 6), - Wrap( - spacing: 4, - runSpacing: 2, - children: note.tags - .take(4) - .map((t) => Chip( - label: Text(t), - materialTapTargetSize: - MaterialTapTargetSize.shrinkWrap, - padding: EdgeInsets.zero, - visualDensity: VisualDensity.compact, - )) - .toList(), - ), - ], - ], - ), - ), - ), - ); - } -} - -// ── Task card ──────────────────────────────────────────────────────────────── - -class TaskLibraryCard extends ConsumerWidget { - final Task task; - const TaskLibraryCard({super.key, required this.task}); - - Color _priorityColor(BuildContext context) { - return switch (task.priority) { - TaskPriority.high => const Color(0xFFEF4444), - TaskPriority.medium => const Color(0xFFF59E0B), - _ => Theme.of(context).colorScheme.onSurfaceVariant, - }; - } - - IconData get _statusIcon => switch (task.status) { - TaskStatus.done => Icons.check_circle, - TaskStatus.inProgress => Icons.timelapse, - _ => Icons.radio_button_unchecked, - }; - - Color _statusColor(BuildContext context) { - final cs = Theme.of(context).colorScheme; - return switch (task.status) { - TaskStatus.done => const Color(0xFF22C55E), - TaskStatus.inProgress => cs.primary, - _ => cs.onSurfaceVariant, - }; - } - - TaskStatus get _nextStatus => switch (task.status) { - TaskStatus.todo => TaskStatus.inProgress, - TaskStatus.inProgress => TaskStatus.done, - _ => TaskStatus.todo, - }; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final theme = Theme.of(context); - return Card( - margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), - child: InkWell( - onTap: () => context - .push(Routes.taskEdit.replaceFirst(':id', '${task.id}')), - borderRadius: BorderRadius.circular(14), - child: Padding( - padding: const EdgeInsets.fromLTRB(8, 10, 14, 10), - child: Row( - children: [ - // Status cycle button - IconButton( - icon: Icon(_statusIcon, color: _statusColor(context)), - onPressed: () => ref - .read(tasksProvider.notifier) - .updateTask(task.id, {'status': _nextStatus.value}), - tooltip: 'Cycle status', - visualDensity: VisualDensity.compact, - ), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - task.title.isNotEmpty ? task.title : 'Untitled', - style: theme.textTheme.titleSmall?.copyWith( - decoration: task.status == TaskStatus.done - ? TextDecoration.lineThrough - : null, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - if (task.dueDate != null) ...[ - const SizedBox(height: 2), - Text( - 'Due ${_formatDate(task.dueDate!)}', - style: theme.textTheme.labelSmall?.copyWith( - color: task.dueDate!.isBefore(DateTime.now()) && - task.status != TaskStatus.done - ? const Color(0xFFEF4444) - : theme.colorScheme.onSurfaceVariant, - ), - ), - ], - ], - ), - ), - if (task.priority != TaskPriority.none && - task.priority != TaskPriority.low) - Container( - width: 8, - height: 8, - decoration: BoxDecoration( - color: _priorityColor(context), - shape: BoxShape.circle, - ), - ), - ], - ), - ), - ), - ); - } -} - -// ── Project card ───────────────────────────────────────────────────────────── - -class ProjectLibraryCard extends StatelessWidget { - final Project project; - const ProjectLibraryCard({super.key, required this.project}); - - Color _parseColor(String? hex) { - if (hex == null || hex.isEmpty) return const Color(0xFF6366F1); - try { - return Color(int.parse(hex.replaceFirst('#', '0xFF'))); - } catch (_) { - return const Color(0xFF6366F1); - } - } - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final color = _parseColor(project.color); - return Card( - margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), - clipBehavior: Clip.antiAlias, - child: InkWell( - onTap: () => context - .push('/projects/${project.id}/tasks'), - borderRadius: BorderRadius.circular(14), - child: Row( - children: [ - // Colour strip - Container(width: 6, height: 64, color: color), - const SizedBox(width: 12), - Expanded( - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - project.title, - style: theme.textTheme.titleSmall, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - if (project.description?.isNotEmpty == true) ...[ - const SizedBox(height: 2), - Text( - project.description!, - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ], - ], - ), - ), - ), - const SizedBox(width: 12), - ], - ), - ), - ); - } -} - -// ── Helpers ─────────────────────────────────────────────────────────────────── - -String _relativeTime(DateTime dt) { - final diff = DateTime.now().difference(dt); - if (diff.inMinutes < 1) return 'just now'; - if (diff.inHours < 1) return '${diff.inMinutes}m ago'; - if (diff.inDays < 1) return '${diff.inHours}h ago'; - if (diff.inDays < 7) return '${diff.inDays}d ago'; - return _formatDate(dt); -} - -String _formatDate(DateTime dt) { - const months = [ - 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', - 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec' - ]; - return '${months[dt.month - 1]} ${dt.day}'; -} diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index eb768d3..bcdce0a 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -6,10 +6,18 @@ #include "generated_plugin_registrant.h" +#include #include +#include void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) flutter_timezone_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterTimezonePlugin"); + flutter_timezone_plugin_register_with_registrar(flutter_timezone_registrar); g_autoptr(FlPluginRegistrar) open_file_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "OpenFileLinuxPlugin"); open_file_linux_plugin_register_with_registrar(open_file_linux_registrar); + g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); + url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); } diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index eb06039..8c4a3aa 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -3,7 +3,9 @@ # list(APPEND FLUTTER_PLUGIN_LIST + flutter_timezone open_file_linux + url_launcher_linux ) list(APPEND FLUTTER_FFI_PLUGIN_LIST diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 104d10e..bd01ad7 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -6,13 +6,17 @@ import FlutterMacOS import Foundation import flutter_inappwebview_macos +import flutter_timezone import open_file_mac import package_info_plus import shared_preferences_foundation +import url_launcher_macos func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { InAppWebViewFlutterPlugin.register(with: registry.registrar(forPlugin: "InAppWebViewFlutterPlugin")) + FlutterTimezonePlugin.register(with: registry.registrar(forPlugin: "FlutterTimezonePlugin")) OpenFilePlugin.register(with: registry.registrar(forPlugin: "OpenFilePlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) + UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) } diff --git a/pubspec.lock b/pubspec.lock index bfc5ee8..ea7ca70 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -957,6 +957,70 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.1" + url_launcher: + dependency: "direct main" + description: + name: url_launcher + sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 + url: "https://pub.dev" + source: hosted + version: "6.3.2" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: "3bb000251e55d4a209aa0e2e563309dc9bb2befea2295fd0cec1f51760aac572" + url: "https://pub.dev" + source: hosted + version: "6.3.29" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0" + url: "https://pub.dev" + source: hosted + version: "6.4.1" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a + url: "https://pub.dev" + source: hosted + version: "3.2.2" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18" + url: "https://pub.dev" + source: hosted + version: "3.2.5" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: d0412fcf4c6b31ecfdb7762359b7206ffba3bbffd396c6d9f9c4616ece476c1f + url: "https://pub.dev" + source: hosted + version: "2.4.2" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f" + url: "https://pub.dev" + source: hosted + version: "3.1.5" vector_math: dependency: transitive description: diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 3b4ee90..cb273b1 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -7,8 +7,17 @@ #include "generated_plugin_registrant.h" #include +#include +#include +#include void RegisterPlugins(flutter::PluginRegistry* registry) { FlutterInappwebviewWindowsPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("FlutterInappwebviewWindowsPluginCApi")); + FlutterTimezonePluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FlutterTimezonePluginCApi")); + PermissionHandlerWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin")); + UrlLauncherWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("UrlLauncherWindows")); } diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 61c79a2..9474322 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -4,6 +4,9 @@ list(APPEND FLUTTER_PLUGIN_LIST flutter_inappwebview_windows + flutter_timezone + permission_handler_windows + url_launcher_windows ) list(APPEND FLUTTER_FFI_PLUGIN_LIST