6232c7c99a
Plans 1-3 implemented: Plan 1 — Foundation - Add google_fonts ^6.2.1 - lib/core/theme.dart: slate-indigo ColorScheme (dark/light), Fraunces headings, GradientButton widget - lib/providers/capture_work_queue_provider.dart: in-memory sequential work queue; CaptureWorkQueueNotifier drains one item at a time; captureResultProvider feeds snackbars to UI - lib/app.dart: wire fabledDarkTheme/fabledLightTheme; replace blocking _QuickCaptureBar with queue-based implementation (progress bar, badge) Plan 2 — Navigation - 3-tab shell: Briefing · Library · Chat (was Notes · Tasks · Projects · Chat) - lib/screens/library/library_screen.dart: unified notes+tasks+projects list with filter pills, status sub-filter for tasks, live search, FAB - lib/widgets/library_item_card.dart: NoteLibraryCard, TaskLibraryCard (status cycle), ProjectLibraryCard - lib/screens/chat/conversations_tab_screen.dart: focused replacement for ConversationsListScreen - Delete 5 dead screens: notes_list, tasks_list, project_list, conversations_list, quick_capture Plan 3 — Briefing - lib/data/models/briefing_conversation.dart - lib/data/api/briefing_api.dart: getToday, getHistory, getMessages, triggerSlot - lib/providers/briefing_provider.dart: BriefingNotifier with sendReply (optimistic + SSE + poll, same pattern as MessagesNotifier) and refresh - lib/widgets/chat_message_bubble.dart: extracted + redesigned shared bubble (ghost user bubbles, left-accent assistant bubbles) - lib/widgets/briefing_digest_card.dart: expandable first-message card - lib/screens/briefing/briefing_screen.dart: digest card, conversation list, streaming reply bar, refresh button, history overflow menu - lib/screens/briefing/briefing_history_screen.dart: read-only past dates Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1126 lines
37 KiB
Markdown
1126 lines
37 KiB
Markdown
# Fabled App Overhaul — Plan 2: Navigation (Shell + Library + Cleanup)
|
|
|
|
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
|
**Prerequisite:** Plan 1 (theme + capture queue) must be complete.
|
|
|
|
**Goal:** Replace the 4-tab shell (Notes · Tasks · Projects · Chat) with a 3-tab shell (Briefing · Library · Chat), build the unified LibraryScreen with filter pills and inline search, and delete the five screens made redundant by this change.
|
|
|
|
**Architecture:** `app.dart`'s `_Shell` is rewritten for 3 tabs. A new `LibraryScreen` replaces the three separate list screens with a unified `GET /api/notes` + `GET /api/tasks` feed filtered by pill selection. A `LibraryItemCard` widget renders note, task, and project rows uniformly. The Briefing tab shows a placeholder screen until Plan 3 ships. Dead screens are deleted.
|
|
|
|
**Tech Stack:** Flutter/Dart, Riverpod, GoRouter, Dio (existing), `GradientButton` from Plan 1 theme.
|
|
|
|
---
|
|
|
|
## File Map
|
|
|
|
**Create:**
|
|
- `lib/screens/briefing/briefing_screen.dart` — placeholder (full implementation in Plan 3)
|
|
- `lib/screens/library/library_screen.dart` — unified list with filter pills + search
|
|
- `lib/widgets/library_item_card.dart` — note / task / project row widget
|
|
|
|
**Modify:**
|
|
- `lib/app.dart` — rewrite `_Shell` for 3 tabs; add `/briefing` and `/library` routes; remove dead routes
|
|
- `lib/core/constants.dart` — add `briefing` and `library` route constants
|
|
- `lib/providers/api_client_provider.dart` — no changes needed (existing notes/tasks providers reused)
|
|
|
|
**Delete:**
|
|
- `lib/screens/notes/notes_list_screen.dart`
|
|
- `lib/screens/tasks/tasks_list_screen.dart`
|
|
- `lib/screens/projects/project_list_screen.dart`
|
|
- `lib/screens/chat/conversations_list_screen.dart`
|
|
- `lib/screens/quick_capture/quick_capture_screen.dart`
|
|
|
|
---
|
|
|
|
## Chunk 1: Constants + Placeholder Briefing Screen
|
|
|
|
### Task 1: Add route constants
|
|
|
|
**Files:**
|
|
- Modify: `lib/core/constants.dart`
|
|
|
|
- [ ] **Step 1: Read `lib/core/constants.dart`**
|
|
|
|
Check the existing `Routes` class. It will have constants like `notes`, `tasks`, etc.
|
|
|
|
- [ ] **Step 2: Add new constants**
|
|
|
|
Add to the `Routes` class:
|
|
```dart
|
|
static const briefing = '/briefing';
|
|
static const library = '/library';
|
|
```
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add lib/core/constants.dart
|
|
git commit -m "feat: add briefing and library route constants"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 2: Briefing placeholder screen
|
|
|
|
**Files:**
|
|
- Create: `lib/screens/briefing/briefing_screen.dart`
|
|
|
|
This is a placeholder — Plan 3 will replace it entirely. It just renders a centred message so the tab is navigable.
|
|
|
|
- [ ] **Step 1: Create `lib/screens/briefing/briefing_screen.dart`**
|
|
|
|
```dart
|
|
import 'package:flutter/material.dart';
|
|
|
|
/// Placeholder — full implementation in Plan 3.
|
|
class BriefingScreen extends StatelessWidget {
|
|
const BriefingScreen({super.key});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
return Scaffold(
|
|
appBar: AppBar(
|
|
title: Text('Briefing', style: theme.textTheme.titleLarge),
|
|
),
|
|
body: Center(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(Icons.wb_sunny_outlined,
|
|
size: 48, color: theme.colorScheme.primary),
|
|
const SizedBox(height: 16),
|
|
Text('Briefing coming soon',
|
|
style: theme.textTheme.titleMedium),
|
|
const SizedBox(height: 8),
|
|
Text('Your daily briefing will appear here.',
|
|
style: theme.textTheme.bodyMedium?.copyWith(
|
|
color: theme.colorScheme.onSurfaceVariant)),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Analyze**
|
|
|
|
```bash
|
|
flutter analyze lib/screens/briefing/briefing_screen.dart
|
|
```
|
|
|
|
Expected: no errors.
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add lib/screens/briefing/briefing_screen.dart
|
|
git commit -m "feat: briefing placeholder screen"
|
|
```
|
|
|
|
---
|
|
|
|
## Chunk 2: Library Screen
|
|
|
|
### Task 3: LibraryItemCard widget
|
|
|
|
**Files:**
|
|
- Create: `lib/widgets/library_item_card.dart`
|
|
|
|
A unified card that renders a note row, a task row, or a project row depending on item type. Tapping navigates to the appropriate detail screen.
|
|
|
|
- [ ] **Step 1: Read the existing data models**
|
|
|
|
Check `lib/data/models/note.dart`, `lib/data/models/task.dart`, `lib/data/models/project.dart` to confirm field names before writing the widget.
|
|
|
|
- [ ] **Step 2: Create `lib/widgets/library_item_card.dart`**
|
|
|
|
```dart
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:go_router/go_router.dart';
|
|
import 'package:intl/intl.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, String? priority) {
|
|
final cs = Theme.of(context).colorScheme;
|
|
return switch (priority) {
|
|
'high' => const Color(0xFFEF4444),
|
|
'medium' => const Color(0xFFF59E0B),
|
|
_ => cs.onSurfaceVariant,
|
|
};
|
|
}
|
|
|
|
IconData _statusIcon(String status) => switch (status) {
|
|
'done' => Icons.check_circle,
|
|
'in_progress' => Icons.timelapse,
|
|
_ => Icons.radio_button_unchecked,
|
|
};
|
|
|
|
Color _statusColor(BuildContext context, String status) {
|
|
final cs = Theme.of(context).colorScheme;
|
|
return switch (status) {
|
|
'done' => const Color(0xFF22C55E),
|
|
'in_progress' => cs.primary,
|
|
_ => cs.onSurfaceVariant,
|
|
};
|
|
}
|
|
|
|
String _nextStatus(String current) => switch (current) {
|
|
'todo' => 'in_progress',
|
|
'in_progress' => 'done',
|
|
_ => '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(task.status),
|
|
color: _statusColor(context, task.status),
|
|
),
|
|
onPressed: () => ref
|
|
.read(tasksProvider.notifier)
|
|
.updateStatus(task.id, _nextStatus(task.status)),
|
|
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 == 'done'
|
|
? TextDecoration.lineThrough
|
|
: null,
|
|
),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
if (task.dueDate != null) ...[
|
|
const SizedBox(height: 2),
|
|
Text(
|
|
'Due ${DateFormat.MMMd().format(task.dueDate!)}',
|
|
style: theme.textTheme.labelSmall?.copyWith(
|
|
color: task.dueDate!.isBefore(DateTime.now()) &&
|
|
task.status != 'done'
|
|
? const Color(0xFFEF4444)
|
|
: theme.colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
if (task.priority != null && task.priority != 'none')
|
|
Container(
|
|
width: 8,
|
|
height: 8,
|
|
decoration: BoxDecoration(
|
|
color: _priorityColor(context, task.priority),
|
|
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: () {}, // Projects have no detail screen in this app
|
|
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) {
|
|
if (dt == null) return '';
|
|
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 DateFormat.MMMd().format(dt);
|
|
}
|
|
```
|
|
|
|
Note: `Routes.noteDetail` and `Routes.taskEdit` use path patterns like `/notes/:id` — check `lib/core/constants.dart` for exact values and adjust `replaceFirst` calls accordingly.
|
|
|
|
Also note: `tasksProvider.notifier.updateStatus()` — check `lib/providers/tasks_provider.dart` to verify this method exists. If not, add it (it calls `PATCH /api/tasks/:id` with `{status}`).
|
|
|
|
Also: `intl` package may need adding to `pubspec.yaml` — check if it's already a transitive dependency via `flutter pub deps`. If not, add `intl: ^0.19.0`.
|
|
|
|
- [ ] **Step 3: Analyze**
|
|
|
|
```bash
|
|
flutter analyze lib/widgets/library_item_card.dart
|
|
```
|
|
|
|
Fix any type errors (common: nullable field access, missing `fromJson` fields on models).
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add lib/widgets/library_item_card.dart
|
|
git commit -m "feat: LibraryItemCard — note, task, and project row widgets"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 4: LibraryScreen
|
|
|
|
**Files:**
|
|
- Create: `lib/screens/library/library_screen.dart`
|
|
|
|
- [ ] **Step 1: Create `lib/screens/library/library_screen.dart`**
|
|
|
|
```dart
|
|
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<LibraryScreen> createState() => _LibraryScreenState();
|
|
}
|
|
|
|
class _LibraryScreenState extends ConsumerState<LibraryScreen> {
|
|
_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<List<Note>> 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<List<Task>> 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 => 'todo',
|
|
_TaskStatusFilter.inProgress => 'in_progress',
|
|
_TaskStatusFilter.done => 'done',
|
|
_ => '',
|
|
};
|
|
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 projects) {
|
|
return projects.when(
|
|
loading: () => const Center(child: CircularProgressIndicator()),
|
|
error: (e, _) => Center(child: Text('Error: $e')),
|
|
data: (data) {
|
|
final list = data as List;
|
|
if (list.isEmpty) {
|
|
return const Center(child: Text('No projects'));
|
|
}
|
|
return RefreshIndicator(
|
|
onRefresh: () async => ref.invalidate(projectsProvider),
|
|
child: ListView.builder(
|
|
itemCount: list.length,
|
|
itemBuilder: (_, i) => ProjectLibraryCard(project: list[i]),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
Widget _buildAllList(
|
|
AsyncValue<List<Note>> notesAsync,
|
|
AsyncValue<List<Task>> tasksAsync,
|
|
) {
|
|
final notes = notesAsync.valueOrNull ?? [];
|
|
final tasks = tasksAsync.valueOrNull ?? [];
|
|
|
|
// 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 ?? DateTime(0), NoteLibraryCard(note: n)));
|
|
}
|
|
}
|
|
for (final t in tasks) {
|
|
if (_matchesSearch(t.title)) {
|
|
items.add((t.updatedAt ?? DateTime(0), 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<void>(
|
|
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);
|
|
},
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Check model field names**
|
|
|
|
Read `lib/data/models/note.dart` and `lib/data/models/task.dart`. Verify:
|
|
- `Note` has: `id`, `title`, `body`, `tags` (List<String>), `updatedAt` (DateTime?)
|
|
- `Task` has: `id`, `title`, `status`, `priority`, `dueDate` (DateTime?), `updatedAt` (DateTime?)
|
|
- `Project` has: `id`, `title`, `description`, `color`
|
|
|
|
Adjust field names in `LibraryScreen` and `LibraryItemCard` if they differ.
|
|
|
|
- [ ] **Step 3: Verify `tasksProvider.notifier` has `updateStatus()`**
|
|
|
|
Check `lib/providers/tasks_provider.dart`. If `updateStatus(int id, String status)` doesn't exist, add it:
|
|
```dart
|
|
Future<void> updateStatus(int id, String status) async {
|
|
await ref.read(tasksRepositoryProvider).updateStatus(id, status);
|
|
ref.invalidateSelf();
|
|
}
|
|
```
|
|
|
|
And add to `lib/data/repositories/tasks_repository.dart`:
|
|
```dart
|
|
Future<void> updateStatus(int id, String status) async {
|
|
await _api.updateStatus(id, status);
|
|
}
|
|
```
|
|
|
|
And add to `lib/data/api/tasks_api.dart`:
|
|
```dart
|
|
Future<void> updateStatus(int id, String status) async {
|
|
try {
|
|
await _dio.patch('/api/tasks/$id/status', data: {'status': status});
|
|
} on DioException catch (e) {
|
|
throw dioToApp(e);
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Analyze**
|
|
|
|
```bash
|
|
flutter analyze lib/screens/library/ lib/widgets/library_item_card.dart
|
|
```
|
|
|
|
Fix any errors.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add lib/screens/library/ lib/widgets/library_item_card.dart
|
|
git commit -m "feat: LibraryScreen — unified notes/tasks/projects with filter pills and search"
|
|
```
|
|
|
|
---
|
|
|
|
## Chunk 3: Shell Rewrite + Dead Code Cleanup
|
|
|
|
### Task 5: Rewrite the shell and routes
|
|
|
|
**Files:**
|
|
- Modify: `lib/app.dart` — `_Shell` class, `routerProvider` routes list
|
|
|
|
- [ ] **Step 1: Update imports in `lib/app.dart`**
|
|
|
|
Add:
|
|
```dart
|
|
import 'screens/briefing/briefing_screen.dart';
|
|
import 'screens/library/library_screen.dart';
|
|
```
|
|
|
|
Remove imports for deleted screens (they will be deleted next):
|
|
```dart
|
|
// Remove these:
|
|
import 'screens/notes/notes_list_screen.dart';
|
|
import 'screens/tasks/tasks_list_screen.dart';
|
|
import 'screens/projects/project_list_screen.dart';
|
|
import 'screens/chat/conversations_list_screen.dart';
|
|
```
|
|
|
|
- [ ] **Step 2: Update the shell tab list**
|
|
|
|
Find `static const _tabs = [Routes.notes, Routes.tasks, Routes.projects, Routes.conversations];`
|
|
|
|
Replace with:
|
|
```dart
|
|
static const _tabs = [Routes.briefing, Routes.library, Routes.conversations];
|
|
```
|
|
|
|
- [ ] **Step 3: Update NavigationBar destinations**
|
|
|
|
Replace the 4-destination `NavigationBar` with:
|
|
```dart
|
|
bottomNavigationBar: NavigationBar(
|
|
selectedIndex: index,
|
|
onDestinationSelected: (i) => context.go(_tabs[i]),
|
|
destinations: const [
|
|
NavigationDestination(
|
|
icon: Icon(Icons.wb_sunny_outlined),
|
|
selectedIcon: Icon(Icons.wb_sunny),
|
|
label: 'Briefing',
|
|
),
|
|
NavigationDestination(
|
|
icon: Icon(Icons.library_books_outlined),
|
|
selectedIcon: Icon(Icons.library_books),
|
|
label: 'Library',
|
|
),
|
|
NavigationDestination(
|
|
icon: Icon(Icons.chat_bubble_outline),
|
|
selectedIcon: Icon(Icons.chat_bubble),
|
|
label: 'Chat',
|
|
),
|
|
],
|
|
),
|
|
```
|
|
|
|
- [ ] **Step 4: Update NavigationRail destinations (wide layout)**
|
|
|
|
Replace the 4-destination `NavigationRail` with:
|
|
```dart
|
|
NavigationRail(
|
|
selectedIndex: index,
|
|
onDestinationSelected: (i) => context.go(_tabs[i]),
|
|
labelType: NavigationRailLabelType.all,
|
|
destinations: const [
|
|
NavigationRailDestination(
|
|
icon: Icon(Icons.wb_sunny_outlined),
|
|
selectedIcon: Icon(Icons.wb_sunny),
|
|
label: Text('Briefing'),
|
|
),
|
|
NavigationRailDestination(
|
|
icon: Icon(Icons.library_books_outlined),
|
|
selectedIcon: Icon(Icons.library_books),
|
|
label: Text('Library'),
|
|
),
|
|
NavigationRailDestination(
|
|
icon: Icon(Icons.chat_bubble_outline),
|
|
selectedIcon: Icon(Icons.chat_bubble),
|
|
label: Text('Chat'),
|
|
),
|
|
],
|
|
),
|
|
```
|
|
|
|
- [ ] **Step 5: Update the ShellRoute in routerProvider**
|
|
|
|
Find the `ShellRoute` with its child `GoRoute`s. Replace:
|
|
```dart
|
|
ShellRoute(
|
|
builder: (context, state, child) => _Shell(child: child),
|
|
routes: [
|
|
GoRoute(path: Routes.briefing, builder: (_, _) => const BriefingScreen()),
|
|
GoRoute(path: Routes.library, builder: (_, _) => const LibraryScreen()),
|
|
GoRoute(path: Routes.conversations, builder: (_, _) => const ConversationsInlineScreen()),
|
|
],
|
|
),
|
|
```
|
|
|
|
For the Chat tab: the existing `ConversationsListScreen` is being deleted. Replace it with an inline conversations list built directly in the ShellRoute, or create a minimal `lib/screens/chat/conversations_tab_screen.dart` that replaces it. The simplest approach: move the conversations list content inline.
|
|
|
|
Create `lib/screens/chat/conversations_tab_screen.dart`:
|
|
```dart
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:go_router/go_router.dart';
|
|
|
|
import '../../core/constants.dart';
|
|
import '../../providers/chat_provider.dart';
|
|
|
|
class ConversationsTabScreen extends ConsumerWidget {
|
|
const ConversationsTabScreen({super.key});
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final theme = Theme.of(context);
|
|
final convsAsync = ref.watch(conversationsProvider);
|
|
|
|
return Scaffold(
|
|
appBar: AppBar(
|
|
title: Text('Chat', style: theme.textTheme.titleLarge),
|
|
actions: [
|
|
IconButton(
|
|
icon: const Icon(Icons.add),
|
|
tooltip: 'New conversation',
|
|
onPressed: () async {
|
|
final conv = await ref
|
|
.read(conversationsProvider.notifier)
|
|
.create('New conversation');
|
|
if (context.mounted) {
|
|
context.push(Routes.chat.replaceFirst(':id', '${conv.id}'));
|
|
}
|
|
},
|
|
),
|
|
],
|
|
),
|
|
body: convsAsync.when(
|
|
loading: () => const Center(child: CircularProgressIndicator()),
|
|
error: (e, _) => Center(child: Text('Error: $e')),
|
|
data: (convs) {
|
|
if (convs.isEmpty) {
|
|
return Center(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(Icons.chat_bubble_outline,
|
|
size: 48,
|
|
color: theme.colorScheme.onSurfaceVariant),
|
|
const SizedBox(height: 16),
|
|
Text('No conversations yet',
|
|
style: theme.textTheme.titleMedium),
|
|
const SizedBox(height: 8),
|
|
FilledButton.icon(
|
|
icon: const Icon(Icons.add),
|
|
label: const Text('Start a conversation'),
|
|
onPressed: () async {
|
|
final conv = await ref
|
|
.read(conversationsProvider.notifier)
|
|
.create('New conversation');
|
|
if (context.mounted) {
|
|
context.push(
|
|
Routes.chat.replaceFirst(':id', '${conv.id}'));
|
|
}
|
|
},
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
return RefreshIndicator(
|
|
onRefresh: () async => ref.invalidate(conversationsProvider),
|
|
child: ListView.builder(
|
|
itemCount: convs.length,
|
|
itemBuilder: (ctx, i) {
|
|
final c = convs[i];
|
|
return ListTile(
|
|
leading: const Icon(Icons.chat_bubble_outline),
|
|
title: Text(c.title, maxLines: 1, overflow: TextOverflow.ellipsis),
|
|
subtitle: c.updatedAt != null
|
|
? Text(
|
|
_relativeTime(c.updatedAt!),
|
|
style: theme.textTheme.labelSmall,
|
|
)
|
|
: null,
|
|
trailing: IconButton(
|
|
icon: const Icon(Icons.delete_outline),
|
|
onPressed: () => _confirmDelete(context, ref, c.id, c.title),
|
|
),
|
|
onTap: () => ctx.push(
|
|
Routes.chat.replaceFirst(':id', '${c.id}')),
|
|
);
|
|
},
|
|
),
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _confirmDelete(
|
|
BuildContext context, WidgetRef ref, int id, String title) async {
|
|
final ok = await showDialog<bool>(
|
|
context: context,
|
|
builder: (_) => AlertDialog(
|
|
title: const Text('Delete conversation?'),
|
|
content: Text('"$title" will be permanently deleted.'),
|
|
actions: [
|
|
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Cancel')),
|
|
FilledButton(onPressed: () => Navigator.pop(context, true), child: const Text('Delete')),
|
|
],
|
|
),
|
|
);
|
|
if (ok == true) {
|
|
await ref.read(conversationsProvider.notifier).delete(id);
|
|
}
|
|
}
|
|
}
|
|
|
|
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 '${dt.day}/${dt.month}/${dt.year}';
|
|
}
|
|
```
|
|
|
|
Then in the ShellRoute, use `ConversationsTabScreen`:
|
|
```dart
|
|
import 'screens/chat/conversations_tab_screen.dart';
|
|
// ...
|
|
GoRoute(path: Routes.conversations, builder: (_, _) => const ConversationsTabScreen()),
|
|
```
|
|
|
|
- [ ] **Step 6: Update initial location**
|
|
|
|
In `GoRouter(initialLocation: ...)`, change:
|
|
```dart
|
|
initialLocation: Routes.splash,
|
|
```
|
|
(This stays — the splash screen handles auth redirect. After auth, redirect to briefing. Update the redirect logic to send authenticated users to `Routes.briefing` instead of falling through to `Routes.notes`.)
|
|
|
|
In the `redirect` callback, ensure unauthenticated users still go to login, and authenticated users landing on `/` or `/notes`/`/tasks`/`/projects` are redirected to `/briefing`. The simplest: no change needed — GoRouter will use the shell's first tab (`Routes.briefing`) naturally as the initial shell route.
|
|
|
|
- [ ] **Step 7: Analyze**
|
|
|
|
```bash
|
|
flutter analyze lib/app.dart lib/screens/
|
|
```
|
|
|
|
Fix any errors.
|
|
|
|
- [ ] **Step 8: Commit**
|
|
|
|
```bash
|
|
git add lib/app.dart lib/screens/chat/conversations_tab_screen.dart lib/core/constants.dart
|
|
git commit -m "feat: 3-tab shell — Briefing, Library, Chat"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 6: Delete dead screens
|
|
|
|
**Files:**
|
|
- Delete: 5 screen files
|
|
|
|
- [ ] **Step 1: Delete the dead files**
|
|
|
|
```bash
|
|
cd /home/bvandeusen/Nextcloud/Projects/fabled_app
|
|
git rm lib/screens/notes/notes_list_screen.dart
|
|
git rm lib/screens/tasks/tasks_list_screen.dart
|
|
git rm lib/screens/projects/project_list_screen.dart
|
|
git rm lib/screens/chat/conversations_list_screen.dart
|
|
git rm lib/screens/quick_capture/quick_capture_screen.dart
|
|
```
|
|
|
|
- [ ] **Step 2: Verify no remaining imports**
|
|
|
|
```bash
|
|
grep -r "notes_list_screen\|tasks_list_screen\|project_list_screen\|conversations_list_screen\|quick_capture_screen" lib/
|
|
```
|
|
|
|
Expected: no output.
|
|
|
|
- [ ] **Step 3: Analyze full project**
|
|
|
|
```bash
|
|
flutter analyze
|
|
```
|
|
|
|
Expected: zero errors.
|
|
|
|
- [ ] **Step 4: Run and verify**
|
|
|
|
```bash
|
|
flutter run --debug
|
|
```
|
|
|
|
Verify:
|
|
- App opens to Briefing tab (placeholder screen)
|
|
- Library tab shows unified list with filter pills
|
|
- Tasks filter → status sub-filter appears
|
|
- Tapping a note navigates to `NoteDetailScreen`
|
|
- Chat tab shows conversations list
|
|
- FAB on Library → bottom sheet with "New note" / "New task"
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git commit -m "chore: delete dead list screens replaced by LibraryScreen"
|
|
```
|
|
|
|
---
|
|
|
|
## Verification Checklist
|
|
|
|
- [ ] `flutter analyze` — zero errors
|
|
- [ ] App opens to Briefing tab
|
|
- [ ] Library → All: notes and tasks interleaved, sorted by date
|
|
- [ ] Library → Notes: only notes
|
|
- [ ] Library → Tasks: tasks + status sub-filter works
|
|
- [ ] Library → Projects: project cards with colour strip
|
|
- [ ] Library search: typing filters results live
|
|
- [ ] Task status cycle: tapping checkbox icon on a task card cycles status
|
|
- [ ] FAB → bottom sheet → "New note" / "New task" navigates correctly
|
|
- [ ] Chat tab: conversations list, new conversation creates and navigates to ChatScreen
|
|
- [ ] No references to deleted files remain
|