diff --git a/lib/screens/chat/conversations_list_screen.dart b/lib/screens/chat/conversations_list_screen.dart index 28dfbf8..5367c45 100644 --- a/lib/screens/chat/conversations_list_screen.dart +++ b/lib/screens/chat/conversations_list_screen.dart @@ -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 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( - 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 { + int? _selectedConvId; - Future _newConversation(BuildContext context, WidgetRef ref) async { + Future _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( + 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); + } + } + }, + ); + }, + ), + ); + }, + ); + } } diff --git a/lib/screens/notes/note_detail_screen.dart b/lib/screens/notes/note_detail_screen.dart index 00e02d4..2a8fb4d 100644 --- a/lib/screens/notes/note_detail_screen.dart +++ b/lib/screens/notes/note_detail_screen.dart @@ -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(); + } } }, ), diff --git a/lib/screens/notes/notes_list_screen.dart b/lib/screens/notes/notes_list_screen.dart index 70ad07f..18981e0 100644 --- a/lib/screens/notes/notes_list_screen.dart +++ b/lib/screens/notes/notes_list_screen.dart @@ -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,140 +17,31 @@ class NotesListScreen extends ConsumerStatefulWidget { class _NotesListScreenState extends ConsumerState { 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( - children: [ - Column( - children: [ - if (_showSearch) - Padding( - padding: const EdgeInsets.symmetric(horizontal: 4), - child: Row( - children: [ - Expanded( - child: TextField( - autofocus: true, - decoration: const InputDecoration( - hintText: 'Search notes…', - border: InputBorder.none, - prefixIcon: Icon(Icons.search), - ), - onChanged: (v) => - setState(() => _search = v.trim().toLowerCase()), - ), - ), - IconButton( - icon: const Icon(Icons.close), - tooltip: 'Close search', - onPressed: () => setState(() { - _showSearch = false; - _search = ''; - }), - ), - ], - ), + body: isWide + ? Row( + children: [ + SizedBox( + width: 300, + child: _buildListPane(notesAsync, isWide), ), - Expanded( - child: notesAsync.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 notes.'), - const SizedBox(height: 4), - TextButton( - onPressed: () => ref.invalidate(notesProvider), - child: const Text('Retry'), - ), - ], - ), - ), - data: (notes) { - final filtered = _search.isEmpty - ? notes - : notes - .where((n) => - n.title.toLowerCase().contains(_search) || - n.body.toLowerCase().contains(_search)) - .toList(); - - if (filtered.isEmpty) { - return Center( - child: Text( - _search.isEmpty - ? 'No notes yet. Tap + to create one.' - : 'No notes match "$_search".', - ), - ); - } - - return RefreshIndicator( - onRefresh: () => ref.refresh(notesProvider.future), - child: ListView.separated( - itemCount: filtered.length, - separatorBuilder: (_, _) => const Divider(height: 1), - itemBuilder: (context, i) { - final note = filtered[i]; - final preview = note.body - .split('\n') - .firstWhere((l) => l.trim().isNotEmpty, - orElse: () => '') - .trim(); - return ListTile( - title: Text(note.title), - subtitle: Text( - preview.isNotEmpty - ? preview - : note.updatedAt - .toLocal() - .toString() - .substring(0, 16), - style: Theme.of(context) - .textTheme - .bodySmall - ?.copyWith( - color: Theme.of(context) - .colorScheme - .onSurfaceVariant, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - onTap: () => context.push( - Routes.noteDetail - .replaceFirst(':id', '${note.id}'), - ), - ); - }, - ), - ); - }, - ), - ), - ], - ), - // Floating search button — only visible when search is closed - if (!_showSearch) - Positioned( - top: 4, - right: 4, - child: IconButton( - icon: const Icon(Icons.search), - tooltip: 'Search', - onPressed: () => setState(() => _showSearch = true), - ), - ), - ], - ), + const VerticalDivider(width: 1), + Expanded(child: _buildDetailPane()), + ], + ) + : _buildListPane(notesAsync, isWide), floatingActionButton: FloatingActionButton( heroTag: 'notes_fab', onPressed: () => context.push(Routes.noteNew), @@ -156,4 +49,156 @@ class _NotesListScreenState extends ConsumerState { ), ); } + + 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> notesAsync, bool isWide) { + return Stack( + children: [ + Column( + children: [ + if (_showSearch) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: Row( + children: [ + Expanded( + child: TextField( + autofocus: true, + decoration: const InputDecoration( + hintText: 'Search notes…', + border: InputBorder.none, + prefixIcon: Icon(Icons.search), + ), + onChanged: (v) => + setState(() => _search = v.trim().toLowerCase()), + ), + ), + IconButton( + icon: const Icon(Icons.close), + tooltip: 'Close search', + onPressed: () => setState(() { + _showSearch = false; + _search = ''; + }), + ), + ], + ), + ), + Expanded( + child: notesAsync.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 notes.'), + const SizedBox(height: 4), + TextButton( + onPressed: () => ref.invalidate(notesProvider), + child: const Text('Retry'), + ), + ], + ), + ), + data: (notes) { + final filtered = _search.isEmpty + ? notes + : notes + .where((n) => + n.title.toLowerCase().contains(_search) || + n.body.toLowerCase().contains(_search)) + .toList(); + + if (filtered.isEmpty) { + return Center( + child: Text( + _search.isEmpty + ? 'No notes yet. Tap + to create one.' + : 'No notes match "$_search".', + ), + ); + } + + return RefreshIndicator( + onRefresh: () => ref.refresh(notesProvider.future), + child: ListView.separated( + itemCount: filtered.length, + separatorBuilder: (_, _) => const Divider(height: 1), + itemBuilder: (context, i) { + final note = filtered[i]; + final preview = note.body + .split('\n') + .firstWhere((l) => l.trim().isNotEmpty, + orElse: () => '') + .trim(); + return ListTile( + title: Text(note.title), + subtitle: Text( + preview.isNotEmpty + ? preview + : note.updatedAt + .toLocal() + .toString() + .substring(0, 16), + style: Theme.of(context) + .textTheme + .bodySmall + ?.copyWith( + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + 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}'), + ); + } + }, + ); + }, + ), + ); + }, + ), + ), + ], + ), + // Floating search button — only visible when search is closed. + if (!_showSearch) + Positioned( + top: 4, + right: 4, + child: IconButton( + icon: const Icon(Icons.search), + tooltip: 'Search', + onPressed: () => setState(() => _showSearch = true), + ), + ), + ], + ); + } }