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,112 +5,169 @@ 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) {
|
||||
final convsAsync = ref.watch(conversationsProvider);
|
||||
ConsumerState<ConversationsListScreen> createState() =>
|
||||
_ConversationsListScreenState();
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
body: convsAsync.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (_, _) => Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.cloud_off, size: 48),
|
||||
const SizedBox(height: 12),
|
||||
const Text('Could not load conversations.'),
|
||||
const SizedBox(height: 4),
|
||||
TextButton(
|
||||
onPressed: () => ref.invalidate(conversationsProvider),
|
||||
child: const Text('Retry'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
data: (convs) {
|
||||
if (convs.isEmpty) {
|
||||
return const Center(
|
||||
child: Text('No conversations yet. Tap + to start one.'));
|
||||
}
|
||||
return RefreshIndicator(
|
||||
onRefresh: () => ref.refresh(conversationsProvider.future),
|
||||
child: ListView.separated(
|
||||
itemCount: convs.length,
|
||||
separatorBuilder: (_, _) => const Divider(height: 1),
|
||||
itemBuilder: (context, i) {
|
||||
final conv = convs[i];
|
||||
return ListTile(
|
||||
leading: const Icon(Icons.chat_bubble_outline),
|
||||
title: Text(
|
||||
conv.title.isNotEmpty ? conv.title : 'New conversation',
|
||||
),
|
||||
subtitle: Text(
|
||||
conv.updatedAt.toLocal().toString().substring(0, 16),
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
onTap: () => context.push(
|
||||
Routes.chat.replaceFirst(':id', '${conv.id}'),
|
||||
),
|
||||
onLongPress: () async {
|
||||
final confirm = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: const Text('Delete conversation?'),
|
||||
content: Text(
|
||||
conv.title.isNotEmpty
|
||||
? conv.title
|
||||
: 'New conversation',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () =>
|
||||
Navigator.pop(dialogContext, false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(dialogContext, true),
|
||||
child: const Text('Delete'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirm == true) {
|
||||
await ref
|
||||
.read(conversationsProvider.notifier)
|
||||
.delete(conv.id);
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
heroTag: 'chat_fab',
|
||||
onPressed: () => _newConversation(context, ref),
|
||||
child: const Icon(Icons.add),
|
||||
),
|
||||
);
|
||||
}
|
||||
class _ConversationsListScreenState
|
||||
extends ConsumerState<ConversationsListScreen> {
|
||||
int? _selectedConvId;
|
||||
|
||||
Future<void> _newConversation(BuildContext context, WidgetRef ref) async {
|
||||
Future<void> _newConversation(bool isWide) async {
|
||||
try {
|
||||
final conv = await ref.read(conversationsProvider.notifier).create('');
|
||||
if (context.mounted) {
|
||||
if (!mounted) return;
|
||||
if (isWide) {
|
||||
setState(() => _selectedConvId = conv.id);
|
||||
} else {
|
||||
context.push(Routes.chat.replaceFirst(':id', '${conv.id}'));
|
||||
}
|
||||
} on AppException catch (e) {
|
||||
if (context.mounted) {
|
||||
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: 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(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.cloud_off, size: 48),
|
||||
const SizedBox(height: 12),
|
||||
const Text('Could not load conversations.'),
|
||||
const SizedBox(height: 4),
|
||||
TextButton(
|
||||
onPressed: () => ref.invalidate(conversationsProvider),
|
||||
child: const Text('Retry'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
data: (convs) {
|
||||
if (convs.isEmpty) {
|
||||
return const Center(
|
||||
child: Text('No conversations yet. Tap + to start one.'));
|
||||
}
|
||||
return RefreshIndicator(
|
||||
onRefresh: () => ref.refresh(conversationsProvider.future),
|
||||
child: ListView.separated(
|
||||
itemCount: convs.length,
|
||||
separatorBuilder: (_, _) => const Divider(height: 1),
|
||||
itemBuilder: (context, i) {
|
||||
final conv = convs[i];
|
||||
return ListTile(
|
||||
leading: const Icon(Icons.chat_bubble_outline),
|
||||
title: Text(
|
||||
conv.title.isNotEmpty ? conv.title : 'New conversation',
|
||||
),
|
||||
subtitle: Text(
|
||||
conv.updatedAt.toLocal().toString().substring(0, 16),
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
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,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: const Text('Delete conversation?'),
|
||||
content: Text(
|
||||
conv.title.isNotEmpty
|
||||
? conv.title
|
||||
: 'New conversation',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () =>
|
||||
Navigator.pop(dialogContext, false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () =>
|
||||
Navigator.pop(dialogContext, true),
|
||||
child: const Text('Delete'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirm == true) {
|
||||
await ref
|
||||
.read(conversationsProvider.notifier)
|
||||
.delete(conv.id);
|
||||
if (mounted && _selectedConvId == conv.id) {
|
||||
setState(() => _selectedConvId = null);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user