This repository has been archived on 2026-06-02. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
FabledApp/docs/superpowers/plans/2026-04-04-knowledge-view.md
T

66 KiB

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:

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
cd /home/bvandeusen/Nextcloud/Projects/fabled_app
flutter analyze lib/core/constants.dart

Expected: No issues found!

  • Step 3: Commit
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:

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:

flutter test test/widget_test.dart

Expected: compilation error (Note not imported) — confirms test is wired.

  • Step 2: Update lib/data/models/note.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:

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'));
    });
  });
}
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:

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
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
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
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
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:

/// 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:

// 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:

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
flutter test test/widget_test.dart

Expected: All tests passed!

  • Step 4: Commit
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

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
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):

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):

final knowledgeApiProvider = Provider<KnowledgeApi>((ref) {
  return KnowledgeApi(ref.watch(dioProvider));
});

final knowledgeRepositoryProvider = Provider<KnowledgeRepository>((ref) {
  return KnowledgeRepository(ref.watch(knowledgeApiProvider));
});
  • Step 4: Analyze
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
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

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
flutter analyze lib/providers/knowledge_provider.dart

Expected: No issues found!

  • Step 3: Commit
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

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
flutter analyze lib/widgets/knowledge_item_card.dart

Expected: No issues found!

  • Step 3: Commit
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

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
flutter analyze lib/screens/knowledge/knowledge_screen.dart

Expected: No issues found!

  • Step 3: Commit
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:

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:

  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:

  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:

          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):

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
flutter analyze lib/screens/notes/note_edit_screen.dart

Expected: No issues found!

  • Step 3: Commit
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:

  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
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:

  @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
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
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
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
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:

      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
flutter analyze lib/screens/library/project_tasks_screen.dart

Expected: No issues found!

  • Step 3: Commit
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):

import 'screens/knowledge/knowledge_screen.dart';
import 'screens/projects/projects_screen.dart';
import 'screens/projects/project_edit_screen.dart';

Remove:

import 'screens/library/library_screen.dart';
  • Step 2: Update the GoRouter routes

Replace the ShellRoute block and the Routes.projectTasks route with:

      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:

      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:

  static const _tabs = [
    Routes.briefing,
    Routes.knowledge,
    Routes.conversations,
    Routes.projects,
  ];

Bottom NavigationBar destinations:

        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:

                      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:

  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
flutter analyze lib/app.dart

Expected: No issues found!

  • Step 5: Full build
flutter build apk --debug

Expected: Build succeeds with Built build/app/outputs/flutter-apk/app-debug.apk

  • Step 6: Delete retired files
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)
flutter analyze

Expected: No issues found!

  • Step 8: Final build
flutter build apk --debug

Expected: Built build/app/outputs/flutter-apk/app-debug.apk

  • Step 9: Commit
git add -A
git commit -m "feat(knowledge): wire 4-tab shell, retire LibraryScreen, complete Knowledge View feature"