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>
37 KiB
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 + searchlib/widgets/library_item_card.dart— note / task / project row widget
Modify:
lib/app.dart— rewrite_Shellfor 3 tabs; add/briefingand/libraryroutes; remove dead routeslib/core/constants.dart— addbriefingandlibraryroute constantslib/providers/api_client_provider.dart— no changes needed (existing notes/tasks providers reused)
Delete:
lib/screens/notes/notes_list_screen.dartlib/screens/tasks/tasks_list_screen.dartlib/screens/projects/project_list_screen.dartlib/screens/chat/conversations_list_screen.dartlib/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:
static const briefing = '/briefing';
static const library = '/library';
- Step 3: Commit
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
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
flutter analyze lib/screens/briefing/briefing_screen.dart
Expected: no errors.
- Step 3: Commit
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
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
flutter analyze lib/widgets/library_item_card.dart
Fix any type errors (common: nullable field access, missing fromJson fields on models).
- Step 4: Commit
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
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:
Notehas:id,title,body,tags(List),updatedAt(DateTime?)Taskhas:id,title,status,priority,dueDate(DateTime?),updatedAt(DateTime?)Projecthas:id,title,description,color
Adjust field names in LibraryScreen and LibraryItemCard if they differ.
- Step 3: Verify
tasksProvider.notifierhasupdateStatus()
Check lib/providers/tasks_provider.dart. If updateStatus(int id, String status) doesn't exist, add it:
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:
Future<void> updateStatus(int id, String status) async {
await _api.updateStatus(id, status);
}
And add to lib/data/api/tasks_api.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
flutter analyze lib/screens/library/ lib/widgets/library_item_card.dart
Fix any errors.
- Step 5: Commit
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—_Shellclass,routerProviderroutes list -
Step 1: Update imports in
lib/app.dart
Add:
import 'screens/briefing/briefing_screen.dart';
import 'screens/library/library_screen.dart';
Remove imports for deleted screens (they will be deleted next):
// 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:
static const _tabs = [Routes.briefing, Routes.library, Routes.conversations];
- Step 3: Update NavigationBar destinations
Replace the 4-destination NavigationBar with:
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:
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 GoRoutes. Replace:
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:
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:
import 'screens/chat/conversations_tab_screen.dart';
// ...
GoRoute(path: Routes.conversations, builder: (_, _) => const ConversationsTabScreen()),
- Step 6: Update initial location
In GoRouter(initialLocation: ...), change:
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
flutter analyze lib/app.dart lib/screens/
Fix any errors.
- Step 8: Commit
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
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
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
flutter analyze
Expected: zero errors.
- Step 4: Run and verify
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
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