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:
@@ -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)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user