Add master-detail layout for Notes and Chat on wide screens

On screens ≥600dp, Notes and Chat show a two-pane layout:
- Left pane (300dp): scrollable list with selection highlight
- Right pane: inline detail (NoteDetailScreen / ChatScreen)

Tapping a list item selects it in wide mode instead of pushing a route.
Creating a new conversation on wide screen auto-selects it in the pane.
Deleting a selected note/conversation clears the right pane.
NoteDetailScreen gains an optional onDeleted callback used by the
embedded pane to clear selection rather than call context.pop().
Tasks keep push-based navigation (edit form has save/pop semantics
that require a full-screen context).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-01 12:48:45 -05:00
parent 2d4c9a9f70
commit 45768e9bb8
3 changed files with 327 additions and 221 deletions
+83 -26
View File
@@ -5,16 +5,80 @@ import 'package:go_router/go_router.dart';
import '../../core/constants.dart';
import '../../core/exceptions.dart';
import '../../providers/chat_provider.dart';
import 'chat_screen.dart';
class ConversationsListScreen extends ConsumerWidget {
class ConversationsListScreen extends ConsumerStatefulWidget {
const ConversationsListScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
ConsumerState<ConversationsListScreen> createState() =>
_ConversationsListScreenState();
}
class _ConversationsListScreenState
extends ConsumerState<ConversationsListScreen> {
int? _selectedConvId;
Future<void> _newConversation(bool isWide) async {
try {
final conv = await ref.read(conversationsProvider.notifier).create('');
if (!mounted) return;
if (isWide) {
setState(() => _selectedConvId = conv.id);
} else {
context.push(Routes.chat.replaceFirst(':id', '${conv.id}'));
}
} on AppException catch (e) {
if (mounted) {
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(e.message)));
}
}
}
@override
Widget build(BuildContext context) {
final convsAsync = ref.watch(conversationsProvider);
final isWide = MediaQuery.of(context).size.width >= 600;
// Clear stale selection when switching to narrow mode.
if (!isWide && _selectedConvId != null) {
_selectedConvId = null;
}
return Scaffold(
body: convsAsync.when(
body: isWide
? Row(
children: [
SizedBox(
width: 300,
child: _buildListPane(convsAsync, isWide),
),
const VerticalDivider(width: 1),
Expanded(child: _buildDetailPane()),
],
)
: _buildListPane(convsAsync, isWide),
floatingActionButton: FloatingActionButton(
heroTag: 'chat_fab',
onPressed: () => _newConversation(isWide),
child: const Icon(Icons.add),
),
);
}
Widget _buildDetailPane() {
if (_selectedConvId == null) {
return const Center(child: Text('Select a conversation to open it.'));
}
return ChatScreen(
key: ValueKey(_selectedConvId),
conversationId: _selectedConvId!,
);
}
Widget _buildListPane(AsyncValue convsAsync, bool isWide) {
return convsAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (_, _) => Center(
child: Column(
@@ -54,9 +118,18 @@ class ConversationsListScreen extends ConsumerWidget {
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
onTap: () => context.push(
selected: isWide && _selectedConvId == conv.id,
selectedTileColor:
Theme.of(context).colorScheme.secondaryContainer,
onTap: () {
if (isWide) {
setState(() => _selectedConvId = conv.id);
} else {
context.push(
Routes.chat.replaceFirst(':id', '${conv.id}'),
),
);
}
},
onLongPress: () async {
final confirm = await showDialog<bool>(
context: context,
@@ -74,7 +147,8 @@ class ConversationsListScreen extends ConsumerWidget {
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.pop(dialogContext, true),
onPressed: () =>
Navigator.pop(dialogContext, true),
child: const Text('Delete'),
),
],
@@ -84,6 +158,9 @@ class ConversationsListScreen extends ConsumerWidget {
await ref
.read(conversationsProvider.notifier)
.delete(conv.id);
if (mounted && _selectedConvId == conv.id) {
setState(() => _selectedConvId = null);
}
}
},
);
@@ -91,26 +168,6 @@ class ConversationsListScreen extends ConsumerWidget {
),
);
},
),
floatingActionButton: FloatingActionButton(
heroTag: 'chat_fab',
onPressed: () => _newConversation(context, ref),
child: const Icon(Icons.add),
),
);
}
Future<void> _newConversation(BuildContext context, WidgetRef ref) async {
try {
final conv = await ref.read(conversationsProvider.notifier).create('');
if (context.mounted) {
context.push(Routes.chat.replaceFirst(':id', '${conv.id}'));
}
} on AppException catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(e.message)));
}
}
}
}
+6 -2
View File
@@ -8,7 +8,9 @@ import '../../providers/notes_provider.dart';
class NoteDetailScreen extends ConsumerWidget {
final int noteId;
const NoteDetailScreen({super.key, required this.noteId});
// Provided when embedded in master-detail: called instead of context.pop().
final VoidCallback? onDeleted;
const NoteDetailScreen({super.key, required this.noteId, this.onDeleted});
@override
Widget build(BuildContext context, WidgetRef ref) {
@@ -51,7 +53,9 @@ class NoteDetailScreen extends ConsumerWidget {
);
if (confirm == true) {
await ref.read(notesProvider.notifier).delete(noteId);
if (context.mounted) context.pop();
if (context.mounted) {
onDeleted != null ? onDeleted!() : context.pop();
}
}
},
),
+55 -10
View File
@@ -3,7 +3,9 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/constants.dart';
import '../../data/models/note.dart';
import '../../providers/notes_provider.dart';
import 'note_detail_screen.dart';
class NotesListScreen extends ConsumerStatefulWidget {
const NotesListScreen({super.key});
@@ -15,13 +17,52 @@ class NotesListScreen extends ConsumerStatefulWidget {
class _NotesListScreenState extends ConsumerState<NotesListScreen> {
bool _showSearch = false;
String _search = '';
int? _selectedNoteId;
@override
Widget build(BuildContext context) {
final notesAsync = ref.watch(notesProvider);
final isWide = MediaQuery.of(context).size.width >= 600;
// Clear stale selection when switching to narrow mode.
if (!isWide && _selectedNoteId != null) {
_selectedNoteId = null;
}
return Scaffold(
body: Stack(
body: isWide
? Row(
children: [
SizedBox(
width: 300,
child: _buildListPane(notesAsync, isWide),
),
const VerticalDivider(width: 1),
Expanded(child: _buildDetailPane()),
],
)
: _buildListPane(notesAsync, isWide),
floatingActionButton: FloatingActionButton(
heroTag: 'notes_fab',
onPressed: () => context.push(Routes.noteNew),
child: const Icon(Icons.add),
),
);
}
Widget _buildDetailPane() {
if (_selectedNoteId == null) {
return const Center(child: Text('Select a note to read it.'));
}
return NoteDetailScreen(
key: ValueKey(_selectedNoteId),
noteId: _selectedNoteId!,
onDeleted: () => setState(() => _selectedNoteId = null),
);
}
Widget _buildListPane(AsyncValue<List<Note>> notesAsync, bool isWide) {
return Stack(
children: [
Column(
children: [
@@ -123,10 +164,20 @@ class _NotesListScreenState extends ConsumerState<NotesListScreen> {
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
onTap: () => context.push(
selected: isWide && _selectedNoteId == note.id,
selectedTileColor: Theme.of(context)
.colorScheme
.secondaryContainer,
onTap: () {
if (isWide) {
setState(() => _selectedNoteId = note.id);
} else {
context.push(
Routes.noteDetail
.replaceFirst(':id', '${note.id}'),
),
);
}
},
);
},
),
@@ -136,7 +187,7 @@ class _NotesListScreenState extends ConsumerState<NotesListScreen> {
),
],
),
// Floating search button — only visible when search is closed
// Floating search button — only visible when search is closed.
if (!_showSearch)
Positioned(
top: 4,
@@ -148,12 +199,6 @@ class _NotesListScreenState extends ConsumerState<NotesListScreen> {
),
),
],
),
floatingActionButton: FloatingActionButton(
heroTag: 'notes_fab',
onPressed: () => context.push(Routes.noteNew),
child: const Icon(Icons.add),
),
);
}
}