diff --git a/packages/stream_chat_flutter/CHANGELOG.md b/packages/stream_chat_flutter/CHANGELOG.md index 71d586ddc8..cf0afe566a 100644 --- a/packages/stream_chat_flutter/CHANGELOG.md +++ b/packages/stream_chat_flutter/CHANGELOG.md @@ -5,6 +5,8 @@ - Polls are now shown translated into the current user's language, following `StreamMessageTranslationConfiguration` like the message text. - Added `PollTranslationX`, `PollOptionTranslationX` and `PollVoteTranslationX`, to read the translations of a poll, its options and its answers. - Added `MessageTranslationLanguage`, the language `StreamMessageContent` shows a message translated into, for custom attachments to show translations too. +- Added `automaticallyImplyLeading` to `StreamChannelPage` and `StreamThreadPage`, which hides the header's back button when `false`. +- Added a `selected` parameter to `StreamThreadListTile`, which highlights the tile, and `StreamThreadListTile.copyWith`. - `StreamMessageListView` now shows who is typing at the newest end of the list with `StreamMessageListTypingIndicator`, replacing the indicator above `StreamChannelPage`'s composer. Replace it with `StreamMessageListViewBuilders.typingIndicator`, or hide it with `StreamMessageListViewConfiguration.showTypingIndicator`. - Added `StreamTypingUsersBuilder`, to build a widget from the users typing in a channel or thread. @@ -38,6 +40,8 @@ - Fixed the attachment picker overflowing short screens, such as a phone in landscape. - Fixed `StreamMessageListView`'s default loading, empty and error states overflowing when the list has little room, such as in landscape with the attachment picker open. - Fixed the composer's autocomplete suggestions stopping short of the screen edge under a side safe-area inset. +- Fixed `StreamThreadListTile` overflowing when narrow; its timestamp now truncates instead. +- Fixed `StreamMessageItem`'s actions modal throwing when the message leaves the screen while the modal is open. ## 10.5.0 diff --git a/packages/stream_chat_flutter/lib/src/message_widget/stream_message_item.dart b/packages/stream_chat_flutter/lib/src/message_widget/stream_message_item.dart index f31ee49e32..5c6a5d18b3 100644 --- a/packages/stream_chat_flutter/lib/src/message_widget/stream_message_item.dart +++ b/packages/stream_chat_flutter/lib/src/message_widget/stream_message_item.dart @@ -836,7 +836,7 @@ class DefaultStreamMessageItem extends StatelessWidget { initialReactionType: initialReaction?.type, ); - if (action is! MessageAction) return; + if (action is! MessageAction || !context.mounted) return; return _onActionTap(context, channel, action).ignore(); } @@ -903,7 +903,7 @@ class DefaultStreamMessageItem extends StatelessWidget { ), ); - if (action is! MessageAction) return; + if (action is! MessageAction || !context.mounted) return; return _onActionTap(context, channel, action).ignore(); } @@ -935,6 +935,8 @@ class DefaultStreamMessageItem extends StatelessWidget { currentUser: currentUser, ); + // Read up front: the modal can outlive this message, and with it `context`. + final configuration = StreamChatConfiguration.of(context); final layout = StreamMessageLayout.of(context); final theme = core.StreamMessageItemTheme.of(context); final defaults = _StreamMessageItemDefaults( @@ -965,7 +967,7 @@ class DefaultStreamMessageItem extends StatelessWidget { context: context, useRootNavigator: false, builder: (_) => StreamChatConfiguration( - data: StreamChatConfiguration.of(context), + data: configuration, child: StreamMessageLayout( // The message is re-rendered on top of the modal scrim, so its // metadata and annotations switch to their on-scrim colors. @@ -984,7 +986,7 @@ class DefaultStreamMessageItem extends StatelessWidget { ), ); - if (action is! MessageAction) return; + if (action is! MessageAction || !context.mounted) return; return _onActionTap(context, channel, action).ignore(); } diff --git a/packages/stream_chat_flutter/lib/src/pages/channel_page.dart b/packages/stream_chat_flutter/lib/src/pages/channel_page.dart index 8faf2192d1..2fcde6e497 100644 --- a/packages/stream_chat_flutter/lib/src/pages/channel_page.dart +++ b/packages/stream_chat_flutter/lib/src/pages/channel_page.dart @@ -60,6 +60,7 @@ class StreamChannelPage extends StatefulWidget { super.key, this.initialScrollIndex, this.initialAlignment, + this.automaticallyImplyLeading = true, this.onBackPressed, this.onChannelAvatarPressed, }); @@ -70,6 +71,11 @@ class StreamChannelPage extends StatefulWidget { /// Initial scroll alignment for the message list. final double? initialAlignment; + /// Whether the header shows a back button. + /// + /// When `false`, [onBackPressed] is never called. Defaults to `true`. + final bool automaticallyImplyLeading; + /// Called when the header's back button is pressed. /// /// Replaces the default action, which pops the current route. When null the @@ -113,6 +119,7 @@ class _StreamChannelPageState extends State { @override Widget build(BuildContext context) { final appBar = StreamChannelHeader( + automaticallyImplyLeading: widget.automaticallyImplyLeading, // Leaving this null keeps the header's default back button, which pops // the route. onBackPressed: widget.onBackPressed, diff --git a/packages/stream_chat_flutter/lib/src/pages/thread_page.dart b/packages/stream_chat_flutter/lib/src/pages/thread_page.dart index 5a9cd024aa..00342721c0 100644 --- a/packages/stream_chat_flutter/lib/src/pages/thread_page.dart +++ b/packages/stream_chat_flutter/lib/src/pages/thread_page.dart @@ -31,6 +31,7 @@ class StreamThreadPage extends StatefulWidget { this.initialScrollIndex, this.initialAlignment, this.onViewInChannelTap, + this.automaticallyImplyLeading = true, this.onBackPressed, }); @@ -46,6 +47,11 @@ class StreamThreadPage extends StatefulWidget { /// Called when the user taps "View in channel". final void Function(Message message)? onViewInChannelTap; + /// Whether the header shows a back button. + /// + /// When `false`, [onBackPressed] is never called. Defaults to `true`. + final bool automaticallyImplyLeading; + /// Called when the header's back button is pressed. /// /// Replaces the default action, which pops the current route. When null the @@ -89,6 +95,7 @@ class _StreamThreadPageState extends State { Widget build(BuildContext context) { final appBar = StreamThreadHeader( parent: widget.parent, + automaticallyImplyLeading: widget.automaticallyImplyLeading, // Leaving this null keeps the header's default back button, which pops // the route. onBackPressed: widget.onBackPressed, diff --git a/packages/stream_chat_flutter/lib/src/scroll_view/thread_scroll_view/stream_thread_list_tile.dart b/packages/stream_chat_flutter/lib/src/scroll_view/thread_scroll_view/stream_thread_list_tile.dart index 150b21d9a3..1577cd93fe 100644 --- a/packages/stream_chat_flutter/lib/src/scroll_view/thread_scroll_view/stream_thread_list_tile.dart +++ b/packages/stream_chat_flutter/lib/src/scroll_view/thread_scroll_view/stream_thread_list_tile.dart @@ -18,11 +18,13 @@ class StreamThreadListTile extends StatelessWidget { User? currentUser, GestureTapCallback? onTap, GestureLongPressCallback? onLongPress, + bool selected = false, }) : props = StreamThreadListTileProps( thread: thread, currentUser: currentUser, onTap: onTap, onLongPress: onLongPress, + selected: selected, ); /// Creates a thread list tile from pre-built [props]. @@ -34,6 +36,26 @@ class StreamThreadListTile extends StatelessWidget { /// The properties configuring this thread list tile. final StreamThreadListTileProps props; + /// Creates a copy of this tile but with the given fields replaced with + /// the new values. + StreamThreadListTile copyWith({ + Key? key, + Thread? thread, + User? currentUser, + GestureTapCallback? onTap, + GestureLongPressCallback? onLongPress, + bool? selected, + }) { + return StreamThreadListTile( + key: key ?? this.key, + thread: thread ?? props.thread, + currentUser: currentUser ?? props.currentUser, + onTap: onTap ?? props.onTap, + onLongPress: onLongPress ?? props.onLongPress, + selected: selected ?? props.selected, + ); + } + @override Widget build(BuildContext context) { final builder = context.chatComponentBuilder(); @@ -49,6 +71,7 @@ class StreamThreadListTileProps { this.currentUser, this.onTap, this.onLongPress, + this.selected = false, }); /// The thread displayed by the tile. @@ -62,6 +85,11 @@ class StreamThreadListTileProps { /// Called when the tile is long pressed. final GestureLongPressCallback? onLongPress; + + /// Whether the tile is highlighted as the current selection. + /// + /// Defaults to `false`. + final bool selected; } class _DefaultStreamThreadListTile extends StatelessWidget { @@ -74,6 +102,7 @@ class _DefaultStreamThreadListTile extends StatelessWidget { @override Widget build(BuildContext context) { final spacing = context.streamSpacing; + final colorScheme = context.streamColorScheme; final theme = StreamThreadListTileTheme.of(context); final defaults = _StreamThreadListTileThemeDefaults(context); @@ -110,11 +139,15 @@ class _DefaultStreamThreadListTile extends StatelessWidget { child: StreamListTileTheme( data: StreamListTileThemeData( contentPadding: effectivePadding, - backgroundColor: .all(effectiveBackgroundColor), + backgroundColor: .resolveWith((states) { + if (!states.contains(WidgetState.selected)) return effectiveBackgroundColor; + return .alphaBlend(colorScheme.backgroundSelected, effectiveBackgroundColor); + }), ), child: StreamListTileContainer( onTap: props.onTap, onLongPress: props.onLongPress, + selected: props.selected, child: Row( spacing: spacing.sm, crossAxisAlignment: CrossAxisAlignment.start, @@ -353,10 +386,12 @@ class ThreadFooter extends StatelessWidget { context.translations.threadReplyCountText(replyCount), style: replyCountStyle, ), - StreamTimestamp( - date: latestActivityAt.toLocal(), - style: timestampStyle, - formatter: timestampFormatter ?? formatRecentDateTime, + Flexible( + child: StreamTimestamp( + date: latestActivityAt.toLocal(), + style: timestampStyle, + formatter: timestampFormatter ?? formatRecentDateTime, + ), ), ], ); diff --git a/packages/stream_chat_flutter/test/src/channel/channel_page_test.dart b/packages/stream_chat_flutter/test/src/channel/channel_page_test.dart index 0daddfcc47..5aae8c0637 100644 --- a/packages/stream_chat_flutter/test/src/channel/channel_page_test.dart +++ b/packages/stream_chat_flutter/test/src/channel/channel_page_test.dart @@ -27,6 +27,18 @@ void main() { expect(pressedChannel, isNotNull); }); + testWidgets('shows a back button by default', (tester) async { + await _pumpChannelPage(tester); + + expect(find.byType(StreamBackButton), findsOneWidget); + }); + + testWidgets('hides the back button when automaticallyImplyLeading is false', (tester) async { + await _pumpChannelPage(tester, automaticallyImplyLeading: false); + + expect(find.byType(StreamBackButton), findsNothing); + }); + testWidgets('tapping back invokes onBackPressed', (tester) async { var backPressed = 0; await _pumpChannelPage(tester, onBackPressed: () => backPressed++); @@ -243,6 +255,7 @@ Future _pumpChannelPage( StreamSurfaceStyle? composerSurfaceStyle, StreamSurfaceStyle? headerSurfaceStyle, void Function(BuildContext context, Channel channel)? onChannelAvatarPressed, + bool automaticallyImplyLeading = true, VoidCallback? onBackPressed, bool pushOntoARoute = false, }) async { @@ -293,6 +306,7 @@ Future _pumpChannelPage( final page = StreamChannelPage( onChannelAvatarPressed: onChannelAvatarPressed, + automaticallyImplyLeading: automaticallyImplyLeading, onBackPressed: onBackPressed, ); diff --git a/packages/stream_chat_flutter/test/src/channel/thread_page_test.dart b/packages/stream_chat_flutter/test/src/channel/thread_page_test.dart index ac563654aa..96048a8227 100644 --- a/packages/stream_chat_flutter/test/src/channel/thread_page_test.dart +++ b/packages/stream_chat_flutter/test/src/channel/thread_page_test.dart @@ -143,6 +143,18 @@ void main() { expect(_backButtonIsFloating(tester), isFalse); }); + testWidgets('shows a back button by default', (tester) async { + await _pumpThreadPage(tester); + + expect(find.byType(StreamBackButton), findsOneWidget); + }); + + testWidgets('hides the back button when automaticallyImplyLeading is false', (tester) async { + await _pumpThreadPage(tester, automaticallyImplyLeading: false); + + expect(find.byType(StreamBackButton), findsNothing); + }); + testWidgets('tapping back invokes onBackPressed', (tester) async { var backPressed = 0; await _pumpThreadPage(tester, onBackPressed: () => backPressed++); @@ -209,6 +221,7 @@ Future _pumpThreadPage( StreamSurfaceStyle surfaceStyle = StreamSurfaceStyle.regular, StreamSurfaceStyle? headerSurfaceStyle, void Function(Message message)? onViewInChannelTap, + bool automaticallyImplyLeading = true, VoidCallback? onBackPressed, bool pushOntoARoute = false, }) async { @@ -275,6 +288,7 @@ Future _pumpThreadPage( final page = StreamThreadPage( parent: parentMessage, onViewInChannelTap: onViewInChannelTap, + automaticallyImplyLeading: automaticallyImplyLeading, onBackPressed: onBackPressed, ); diff --git a/packages/stream_chat_flutter/test/src/message_widget/stream_message_item_actions_modal_test.dart b/packages/stream_chat_flutter/test/src/message_widget/stream_message_item_actions_modal_test.dart new file mode 100644 index 0000000000..1785b03c24 --- /dev/null +++ b/packages/stream_chat_flutter/test/src/message_widget/stream_message_item_actions_modal_test.dart @@ -0,0 +1,89 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +import '../mocks.dart'; + +void main() { + testWidgets('keeps the actions modal working after its message leaves the tree', (tester) async { + final client = MockClient(); + final channel = _mockChannel(client); + + await tester.pumpWidget(_app(client: client, channel: channel, showMessage: true)); + await tester.pumpAndSettle(); + await _longPress(tester, find.byType(StreamMessageItem)); + + // Rebuilding the app rebuilds the navigator's routes, the open modal among them. + await tester.pumpWidget(_app(client: client, channel: channel, showMessage: false)); + + expect(tester.takeException(), isNull); + expect(find.byKey(const Key('MessageItem')), findsOneWidget); + }); + + testWidgets('StreamMessageItem drops a modal action picked after its message leaves the tree', (tester) async { + final client = MockClient(); + final channel = _mockChannel(client); + final threadTaps = []; + void onThreadTap(Message parent, Message? _) => threadTaps.add(parent); + + await tester.pumpWidget(_app(client: client, channel: channel, showMessage: true, onThreadTap: onThreadTap)); + await tester.pumpAndSettle(); + await _longPress(tester, find.byType(StreamMessageItem)); + + await tester.pumpWidget(_app(client: client, channel: channel, showMessage: false, onThreadTap: onThreadTap)); + await tester.tap(find.text('Thread Reply')); + await tester.pumpAndSettle(); + + expect(threadTaps, isEmpty); + }); +} + +MockChannel _mockChannel(MockClient client) { + final currentUser = OwnUser(id: 'current-user'); + final clientState = MockClientState(); + final channel = MockChannel(ownCapabilities: const [ChannelCapability.sendMessage, ChannelCapability.sendReply]); + final channelState = MockChannelState(); + + when(() => client.state).thenReturn(clientState); + when(() => clientState.currentUser).thenReturn(currentUser); + when(() => clientState.currentUserStream).thenAnswer((_) => Stream.value(currentUser)); + when(() => channel.client).thenReturn(client); + when(() => channel.state).thenReturn(channelState); + + return channel; +} + +Widget _app({ + required MockClient client, + required MockChannel channel, + required bool showMessage, + void Function(Message, Message?)? onThreadTap, +}) { + final message = Message( + id: 'test-message', + text: 'Long press me', + createdAt: DateTime(2026), + user: User(id: 'other-user'), + state: MessageState.sent, + ); + + return MaterialApp( + builder: (context, child) => StreamChat(client: client, child: child), + home: StreamChannel( + channel: channel, + child: Scaffold( + body: showMessage ? StreamMessageItem(message: message, onThreadTap: onThreadTap) : const SizedBox.shrink(), + ), + ), + ); +} + +// Holds past kLongPressTimeout, which a plain tester.longPress does not +// reliably cross. +Future _longPress(WidgetTester tester, Finder finder) async { + final gesture = await tester.startGesture(tester.getCenter(finder)); + await tester.pump(const Duration(milliseconds: 700)); + await gesture.up(); + await tester.pumpAndSettle(); +} diff --git a/packages/stream_chat_flutter/test/src/scroll_view/thread_scroll_view/stream_thread_list_tile_test.dart b/packages/stream_chat_flutter/test/src/scroll_view/thread_scroll_view/stream_thread_list_tile_test.dart index 690e21f151..c5a0a1ba86 100644 --- a/packages/stream_chat_flutter/test/src/scroll_view/thread_scroll_view/stream_thread_list_tile_test.dart +++ b/packages/stream_chat_flutter/test/src/scroll_view/thread_scroll_view/stream_thread_list_tile_test.dart @@ -74,6 +74,52 @@ void main() { ); } + testWidgets('truncates the timestamp instead of overflowing on a narrow width', (tester) async { + final tile = StreamThreadListTile(thread: thread, currentUser: user2); + + await tester.pumpWidget(_wrapWithMaterialApp(SizedBox(width: 240, child: tile))); + + expect(tester.takeException(), isNull); + }); + + testWidgets('tints the background when selected', (tester) async { + final tile = StreamThreadListTile(thread: thread, currentUser: user2, selected: true); + + await tester.pumpWidget(_wrapWithMaterialApp(tile)); + + final colorScheme = tester.element(find.byType(StreamThreadListTile)).streamColorScheme; + expect(_tileBackground(tester), Color.alphaBlend(colorScheme.backgroundSelected, colorScheme.backgroundApp)); + }); + + testWidgets('does not tint the background by default', (tester) async { + final tile = StreamThreadListTile(thread: thread, currentUser: user2); + + await tester.pumpWidget(_wrapWithMaterialApp(tile)); + + final colorScheme = tester.element(find.byType(StreamThreadListTile)).streamColorScheme; + expect(_tileBackground(tester), colorScheme.backgroundApp); + }); + + testWidgets('announces itself as selected when selected', (tester) async { + final handle = tester.ensureSemantics(); + final tile = StreamThreadListTile(thread: thread, currentUser: user2, selected: true); + + await tester.pumpWidget(_wrapWithMaterialApp(tile)); + + expect(tester.getSemantics(find.byType(StreamThreadListTile)), isSemantics(isSelected: true)); + handle.dispose(); + }); + + test('copyWith replaces selected and keeps the other fields', () { + final tile = StreamThreadListTile(thread: thread, currentUser: user2); + + final copy = tile.copyWith(selected: true); + + expect(copy.props.selected, isTrue); + expect(copy.props.thread, thread); + expect(copy.props.currentUser, user2); + }); + group('StreamThreadListTile a11y', () { testWidgets( 'merges children into a single accessible node with the composed label', @@ -194,6 +240,12 @@ void main() { }); } +// The background color the tile paints behind its content. +Color? _tileBackground(WidgetTester tester) { + final ink = find.descendant(of: find.byType(StreamThreadListTile), matching: find.byType(Ink)); + return (tester.widget(ink).decoration! as ShapeDecoration).color; +} + Widget _wrapWithMaterialApp( Widget widget, { Brightness brightness = Brightness.light, diff --git a/sample_app/lib/app.dart b/sample_app/lib/app.dart index 92ba5664c8..c098b5173c 100644 --- a/sample_app/lib/app.dart +++ b/sample_app/lib/app.dart @@ -93,7 +93,7 @@ class _StreamChatSampleAppState extends State return; } debugPrint('[notif-tap] navigating to channel=$cid'); - GoRouter.of(ctx).pushNamed( + GoRouter.of(ctx).goNamed( Routes.CHANNEL_PAGE.name, pathParameters: Routes.CHANNEL_PAGE.params(channel), ); diff --git a/sample_app/lib/pages/channel_list_page.dart b/sample_app/lib/pages/channel_list_page.dart index aa6ff4980d..d8b1d668c8 100644 --- a/sample_app/lib/pages/channel_list_page.dart +++ b/sample_app/lib/pages/channel_list_page.dart @@ -14,6 +14,7 @@ import '../utils/location_provider.dart'; import '../utils/platform_support.dart'; import '../utils/shared_location_service.dart'; import '../widgets/channel_list.dart'; +import '../widgets/split_view.dart'; import 'draft_list_page.dart'; import 'reminders_page.dart'; import 'thread_list_page.dart'; @@ -45,6 +46,7 @@ class _ChannelListPageState extends State { final colorScheme = context.streamColorScheme; final config = context.sampleAppConfig; + final isExpanded = AdaptiveSplitView.isExpandedOf(context); final allTabs = <_TabDef>[ _TabDef( @@ -89,8 +91,10 @@ class _ChannelListPageState extends State { backgroundColor: colorScheme.backgroundApp, appBar: StreamChannelListHeader( title: Text(enabledTabs[_currentIndex].navItem.label, style: textTheme.headingSm), + // Beside the channel, the drawer slides in over both panes. + onUserAvatarPressed: isExpanded ? (_) => AdaptiveSplitView.openDrawerOf(context) : null, ), - drawer: LeftDrawer(user: user), + drawer: isExpanded ? null : const LeftDrawer(), // Keep the scaffold's bottom-slot layout in sync with the nav bar's own // surface style, so a nav-bar-only override is applied fully instead of // just partly (the nav bar rendering vs. the scaffold inset). @@ -147,15 +151,13 @@ class _TabDef { } class LeftDrawer extends StatelessWidget { - const LeftDrawer({ - super.key, - required this.user, - }); - - final User user; + const LeftDrawer({super.key}); @override Widget build(BuildContext context) { + final user = StreamChat.of(context).currentUser; + if (user == null) return const SizedBox.shrink(); + final colorScheme = context.streamColorScheme; final textTheme = context.streamTextTheme; final icons = context.streamIcons; diff --git a/sample_app/lib/pages/channel_placeholder_page.dart b/sample_app/lib/pages/channel_placeholder_page.dart new file mode 100644 index 0000000000..5a649d2ff4 --- /dev/null +++ b/sample_app/lib/pages/channel_placeholder_page.dart @@ -0,0 +1,23 @@ +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +/// A page that fills the channel pane while no channel is open. +class ChannelPlaceholderPage extends StatelessWidget { + const ChannelPlaceholderPage({super.key}); + + @override + Widget build(BuildContext context) { + final icons = context.streamIcons; + final colorScheme = context.streamColorScheme; + + return StreamScaffold( + backgroundColor: colorScheme.backgroundApp, + body: Center( + child: StreamScrollViewEmptyWidget( + emptyIcon: Icon(icons.messageBubblesLarge), + emptyTitle: const Text('Select a chat'), + ), + ), + ); + } +} diff --git a/sample_app/lib/pages/choose_user_page.dart b/sample_app/lib/pages/choose_user_page.dart index 244e3b7265..9e86085971 100644 --- a/sample_app/lib/pages/choose_user_page.dart +++ b/sample_app/lib/pages/choose_user_page.dart @@ -66,10 +66,12 @@ class ChooseUserPage extends StatelessWidget { return ListTile( visualDensity: VisualDensity.compact, onTap: () async { - showDialog( + final navigator = Navigator.of(context, rootNavigator: true); + final progress = DialogRoute( barrierDismissible: false, context: context, barrierColor: context.streamColorScheme.backgroundOverlayLight, + themes: InheritedTheme.capture(from: context, to: navigator.context), builder: (context) => Center( child: Container( decoration: BoxDecoration( @@ -84,6 +86,7 @@ class ChooseUserPage extends StatelessWidget { ), ), ); + navigator.push(progress); final router = GoRouter.of(context); @@ -95,8 +98,9 @@ class ChooseUserPage extends StatelessWidget { baseUrl: kStreamBaseUrl.isEmpty ? null : kStreamBaseUrl, ); } finally { - // Pop the progress dialog regardless of outcome. - router.pop(); + // Remove the progress dialog regardless of outcome, unless the + // redirect to the channel list already took it with this page. + if (progress.isActive) navigator.removeRoute(progress); } // The router's redirect will forward an diff --git a/sample_app/lib/pages/draft_list_page.dart b/sample_app/lib/pages/draft_list_page.dart index b30b888cb8..b6bc407fdc 100644 --- a/sample_app/lib/pages/draft_list_page.dart +++ b/sample_app/lib/pages/draft_list_page.dart @@ -1,7 +1,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_slidable/flutter_slidable.dart'; +import 'package:go_router/go_router.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; +import '../routes/routes.dart'; +import '../widgets/split_view.dart'; import '../widgets/stream_draft_list_view.dart'; class DraftListPage extends StatefulWidget { @@ -24,6 +27,13 @@ class _DraftListPageState extends State { @override Widget build(BuildContext context) { + final openRoute = switch (AdaptiveSplitView.isExpandedOf(context)) { + true => GoRouterState.of(context), + false => null, + }; + final openCid = openRoute?.pathParameters['cid']; + final openThreadId = openRoute?.uri.queryParameters['pid']; + return RefreshIndicator( onRefresh: controller.refresh, child: StreamDraftListView( @@ -54,7 +64,9 @@ class _DraftListPageState extends State { ), ], ), - child: defaultWidget, + child: defaultWidget.copyWith( + selected: draft.channelCid == openCid && draft.parentId == openThreadId, + ), ); }, onDraftTap: (draft) { @@ -63,21 +75,15 @@ class _DraftListPageState extends State { final [channelType, channelId] = draft.channelCid.split(':'); final channel = client.channel(channelType, id: channelId); - Navigator.of(context).push( - MaterialPageRoute( - builder: (context) { - return StreamChannel( - channel: channel, - initialMessageId: draft.parentId, - child: switch (draft.parentMessage) { - final parent? => StreamThreadPage( - parent: parent.copyWith(draft: draft), - ), - _ => const StreamChannelPage(), - }, - ); - }, - ), + GoRouter.of(context).goNamed( + Routes.CHANNEL_PAGE.name, + pathParameters: Routes.CHANNEL_PAGE.params(channel), + queryParameters: switch (draft.parentId) { + final parentId? => {'mid': parentId, 'pid': parentId}, + _ => const {}, + }, + // The thread's parent may not be loaded in the channel yet. + extra: draft.parentMessage?.copyWith(draft: draft), ); }, ), diff --git a/sample_app/lib/pages/reminders_page.dart b/sample_app/lib/pages/reminders_page.dart index 4df2cc66a7..4d5bbfc26a 100644 --- a/sample_app/lib/pages/reminders_page.dart +++ b/sample_app/lib/pages/reminders_page.dart @@ -7,6 +7,7 @@ import 'package:stream_chat_flutter/stream_chat_flutter.dart'; import '../routes/routes.dart'; import '../widgets/reminder_dialog.dart'; +import '../widgets/split_view.dart'; class RemindersPage extends StatefulWidget { const RemindersPage({super.key}); @@ -48,6 +49,13 @@ class _RemindersPageState extends State { @override Widget build(BuildContext context) { + final openRoute = switch (AdaptiveSplitView.isExpandedOf(context)) { + true => GoRouterState.of(context), + false => null, + }; + final openCid = openRoute?.pathParameters['cid']; + final openMessageId = openRoute?.uri.queryParameters['mid']; + return NestedScrollView( headerSliverBuilder: (_, __) => [ SliverPadding( @@ -118,6 +126,7 @@ class _RemindersPageState extends State { ), child: MessageReminderListTile( reminder: reminder, + selected: reminder.channelCid == openCid && reminder.messageId == openMessageId, onReminderTap: () { final client = StreamChat.of(context).client; @@ -307,34 +316,41 @@ class MessageReminderListTile extends StatelessWidget { super.key, required this.reminder, this.onReminderTap, + this.selected = false, }); final MessageReminder reminder; final VoidCallback? onReminderTap; + /// Whether this tile is highlighted as the open reminder. + final bool selected; + @override Widget build(BuildContext context) { final channel = reminder.channel; final channelName = channel?.formatName(currentUser: reminder.user); - return InkWell( - onTap: onReminderTap, - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 8), - child: Column( - spacing: 4, - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - MessageReminderChannelName(channelName: channelName), - MessageReminderStatus(remindAt: reminder.remindAt), - ], - ), - MessageReminderMessageText(message: reminder.message), - ], + return Material( + color: selected ? context.streamColorScheme.backgroundSelected : Colors.transparent, + child: InkWell( + onTap: onReminderTap, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 8), + child: Column( + spacing: 4, + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + MessageReminderChannelName(channelName: channelName), + MessageReminderStatus(remindAt: reminder.remindAt), + ], + ), + MessageReminderMessageText(message: reminder.message), + ], + ), ), ), ); diff --git a/sample_app/lib/pages/thread_list_page.dart b/sample_app/lib/pages/thread_list_page.dart index 36e78e835c..ee554f6842 100644 --- a/sample_app/lib/pages/thread_list_page.dart +++ b/sample_app/lib/pages/thread_list_page.dart @@ -1,9 +1,9 @@ -import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; import '../routes/routes.dart'; +import '../widgets/split_view.dart'; class ThreadListPage extends StatefulWidget { const ThreadListPage({super.key}); @@ -25,6 +25,11 @@ class _ThreadListPageState extends State { @override Widget build(BuildContext context) { + final openThreadId = switch (AdaptiveSplitView.isExpandedOf(context)) { + true => GoRouterState.of(context).uri.queryParameters['pid'], + false => null, + }; + return ValueListenableBuilder>( valueListenable: controller.unseenThreadIds, builder: (context, unseenThreadIds, child) => StreamUnreadThreadsBanner( @@ -38,46 +43,19 @@ class _ThreadListPageState extends State { ), child: StreamThreadListView( controller: controller, - onThreadTap: (thread) async { - final channelCid = thread.channelCid; - - final channel = StreamChat.of(context).client.channel( - channelCid.split(':')[0], - id: channelCid.split(':')[1], - ); + itemBuilder: (context, threads, index, defaultWidget) { + return defaultWidget.copyWith(selected: threads[index].parentMessageId == openThreadId); + }, + onThreadTap: (thread) { + final [type, id] = thread.channelCid.split(':'); + final channel = StreamChat.of(context).client.channel(type, id: id); - Navigator.of(context).push( - MaterialPageRoute( - builder: (context) { - return StreamChannel( - channel: channel, - initialMessageId: thread.draft?.parentId, - child: BetterStreamBuilder( - initialData: thread.parentMessage, - stream: channel.state?.messagesStream - .map( - (messages) => messages.firstWhereOrNull( - (m) => m.id == thread.parentMessage?.id, - ), - ) - .where((msg) => msg != null) - .cast(), - builder: (_, parentMessage) { - return StreamThreadPage( - parent: parentMessage, - onViewInChannelTap: (message) { - GoRouter.of(context).goNamed( - Routes.CHANNEL_PAGE.name, - pathParameters: Routes.CHANNEL_PAGE.params(channel), - queryParameters: {'mid': message.id}, - ); - }, - ); - }, - ), - ); - }, - ), + GoRouter.of(context).goNamed( + Routes.CHANNEL_PAGE.name, + pathParameters: Routes.CHANNEL_PAGE.params(channel), + queryParameters: {'mid': thread.parentMessageId, 'pid': thread.parentMessageId}, + // The thread's parent may not be loaded in the channel yet. + extra: thread.parentMessage, ); }, ), diff --git a/sample_app/lib/routes/app_routes.dart b/sample_app/lib/routes/app_routes.dart index 47ca1295b1..f9f8f7342d 100644 --- a/sample_app/lib/routes/app_routes.dart +++ b/sample_app/lib/routes/app_routes.dart @@ -5,6 +5,7 @@ import 'package:stream_chat_flutter/stream_chat_flutter.dart'; import '../pages/advanced_options_page.dart'; import '../pages/channel_list_page.dart'; +import '../pages/channel_placeholder_page.dart'; import '../pages/chat_info_screen.dart'; import '../pages/choose_user_page.dart'; import '../pages/group_chat_details_screen.dart'; @@ -12,93 +13,147 @@ import '../pages/group_info_screen.dart'; import '../pages/new_chat_screen.dart'; import '../pages/new_group_chat_screen.dart'; import '../state/new_group_chat_state.dart'; +import '../widgets/split_view.dart'; import 'routes.dart'; final appRoutes = [ - GoRoute( - name: Routes.CHANNEL_LIST_PAGE.name, - path: Routes.CHANNEL_LIST_PAGE.path, - builder: (BuildContext context, GoRouterState state) => const ChannelListPage(), + ShellRoute( + builder: (BuildContext context, GoRouterState state, Widget child) { + return AdaptiveSplitView( + primary: const ChannelListPage(), + secondary: child, + drawer: const LeftDrawer(), + ); + }, routes: [ GoRoute( - name: Routes.CHANNEL_PAGE.name, - path: Routes.CHANNEL_PAGE.path, - builder: (context, state) { - final channel = _resolveChannel(context, state); - final messageId = state.uri.queryParameters['mid']; - final parentId = state.uri.queryParameters['pid']; + name: Routes.CHANNEL_LIST_PAGE.name, + path: Routes.CHANNEL_LIST_PAGE.path, + pageBuilder: (BuildContext context, GoRouterState state) { + return AdaptiveSplitViewPage( + key: state.pageKey, + name: state.name, + child: const AdaptiveSplitViewRoot(placeholder: ChannelPlaceholderPage()), + ); + }, + routes: [ + GoRoute( + name: Routes.CHANNEL_PAGE.name, + path: Routes.CHANNEL_PAGE.path, + pageBuilder: (context, state) { + final channel = _resolveChannel(context, state); + final messageId = state.uri.queryParameters['mid']; + final parentId = state.uri.queryParameters['pid']; - // Thread deep-links require the parent message to already be in - // channel state. On cold cids (e.g. notification-tap into an - // unwatched channel) the state is null and we fall through to - // ChannelPage; the user lands on the channel rather than the - // thread, which is a degraded but functional UX. - Message? parentMessage; - if (parentId != null) { - parentMessage = channel.state?.messages.firstWhereOrNull((it) => it.id == parentId); - } + // Thread deep-links require the parent message to already be in + // channel state, unless the caller passes it as `extra`. On cold + // cids (e.g. notification-tap into an unwatched channel) the + // state is null and we fall through to ChannelPage; the user + // lands on the channel rather than the thread, which is a + // degraded but functional UX. + Message? parentMessage; + if (parentId != null) { + parentMessage = switch (state.extra) { + final Message parent when parent.id == parentId => parent, + _ => channel.state?.messages.firstWhereOrNull((it) => it.id == parentId), + }; + } - return StreamChannel( - channel: channel, - initialMessageId: messageId, - openAtFirstUnread: false, - child: Builder( - builder: (context) { - return (parentMessage != null) - ? StreamThreadPage(parent: parentMessage) - : StreamChannelPage( - onChannelAvatarPressed: (context, channel) { - final isOneToOne = channel.isOneToOne; - final currentUserId = StreamChat.of(context).currentUser?.id; + final page = StreamChannel( + channel: channel, + initialMessageId: messageId, + openAtFirstUnread: false, + child: Builder( + builder: (context) { + // Both panes are visible in a split, so there is nothing to go back to. + final showBackButton = !AdaptiveSplitView.isExpandedOf(context); - final channelMembers = channel.state?.members ?? []; - final otherUser = (isOneToOne && currentUserId != null) - ? channelMembers.firstWhereOrNull((m) => m.userId != currentUserId)?.user - : null; + if (parentMessage case final parent?) { + return BetterStreamBuilder( + initialData: parent, + // Keeps the last known parent while it is not among the loaded messages. + stream: channel.state?.messagesStream + .map((messages) => messages.firstWhereOrNull((it) => it.id == parent.id)) + .where((it) => it != null), + builder: (context, parent) => StreamThreadPage( + parent: parent, + automaticallyImplyLeading: showBackButton, + onViewInChannelTap: (message) { + GoRouter.of(context).goNamed( + Routes.CHANNEL_PAGE.name, + pathParameters: Routes.CHANNEL_PAGE.params(channel), + queryParameters: {'mid': message.id}, + ); + }, + ), + ); + } - final router = GoRouter.of(context); + return StreamChannelPage( + automaticallyImplyLeading: showBackButton, + onChannelAvatarPressed: (context, channel) { + final isOneToOne = channel.isOneToOne; + final currentUserId = StreamChat.of(context).currentUser?.id; - if (otherUser != null) { - router.pushNamed( - Routes.CHAT_INFO_SCREEN.name, - pathParameters: Routes.CHAT_INFO_SCREEN.params(channel), - extra: otherUser, - ); - return; - } + final channelMembers = channel.state?.members ?? []; + final otherUser = (isOneToOne && currentUserId != null) + ? channelMembers.firstWhereOrNull((m) => m.userId != currentUserId)?.user + : null; + + final router = GoRouter.of(context); + if (otherUser != null) { router.pushNamed( - Routes.GROUP_INFO_SCREEN.name, - pathParameters: Routes.GROUP_INFO_SCREEN.params(channel), + Routes.CHAT_INFO_SCREEN.name, + pathParameters: Routes.CHAT_INFO_SCREEN.params(channel), + extra: otherUser, ); - }, - ); - }, - ), - ); - }, - routes: [ - GoRoute( - name: Routes.CHAT_INFO_SCREEN.name, - path: Routes.CHAT_INFO_SCREEN.path, - builder: (BuildContext context, GoRouterState state) { - return StreamChannel.value( - channel: _resolveChannel(context, state), - child: ChatInfoScreen( - user: state.extra as User?, + return; + } + + router.pushNamed( + Routes.GROUP_INFO_SCREEN.name, + pathParameters: Routes.GROUP_INFO_SCREEN.params(channel), + ); + }, + ); + }, ), ); - }, - ), - GoRoute( - name: Routes.GROUP_INFO_SCREEN.name, - path: Routes.GROUP_INFO_SCREEN.path, - builder: (BuildContext context, GoRouterState state) { - return StreamChannel.value( - channel: _resolveChannel(context, state), - child: const GroupInfoScreen(), + + // Opening another channel or thread in place replaces the page, so + // nothing carries over from the last one: neither its state, such + // as the composer's text, nor the menus open on it. + return AdaptiveSplitViewPage( + key: ValueKey((channel.cid, parentMessage?.id)), + name: state.name, + child: page, ); }, + routes: [ + GoRoute( + name: Routes.CHAT_INFO_SCREEN.name, + path: Routes.CHAT_INFO_SCREEN.path, + builder: (BuildContext context, GoRouterState state) { + return StreamChannel.value( + channel: _resolveChannel(context, state), + child: ChatInfoScreen( + user: state.extra as User?, + ), + ); + }, + ), + GoRoute( + name: Routes.GROUP_INFO_SCREEN.name, + path: Routes.GROUP_INFO_SCREEN.path, + builder: (BuildContext context, GoRouterState state) { + return StreamChannel.value( + channel: _resolveChannel(context, state), + child: const GroupInfoScreen(), + ); + }, + ), + ], ), ], ), diff --git a/sample_app/lib/utils/window_size_class.dart b/sample_app/lib/utils/window_size_class.dart new file mode 100644 index 0000000000..609f5c0807 --- /dev/null +++ b/sample_app/lib/utils/window_size_class.dart @@ -0,0 +1,76 @@ +import 'package:flutter/widgets.dart'; + +/// How much room a window has across, from the narrowest class to the widest. +enum WindowWidthClass { + /// Narrower than 600, such as a phone in portrait. + compact(0), + + /// At least 600 wide, such as a foldable or a tablet in portrait. + medium(600), + + /// At least 840 wide, such as a foldable or a tablet in landscape. + expanded(840), + + /// At least 1200 wide, such as a desktop window. + large(1200), + + /// At least 1600 wide, such as a large desktop window. + extraLarge(1600); + + const WindowWidthClass(this.minWidth); + + /// The narrowest a window of this class is. + final double minWidth; + + /// The class of a window that is [width] wide. + static WindowWidthClass fromWidth(double width) => values.lastWhere((it) => width >= it.minWidth); + + /// Whether this class is [other] or a wider one. + bool isAtLeast(WindowWidthClass other) => index >= other.index; +} + +/// How much room a window has from top to bottom, from the shortest class to +/// the tallest. +enum WindowHeightClass { + /// Shorter than 480, such as a phone in landscape. + compact(0), + + /// At least 480 tall, such as a phone in portrait or a tablet in landscape. + medium(480), + + /// At least 900 tall, such as a tablet in portrait. + expanded(900); + + const WindowHeightClass(this.minHeight); + + /// The shortest a window of this class is. + final double minHeight; + + /// The class of a window that is [height] tall. + static WindowHeightClass fromHeight(double height) => values.lastWhere((it) => height >= it.minHeight); + + /// Whether this class is [other] or a taller one. + bool isAtLeast(WindowHeightClass other) => index >= other.index; +} + +/// The width and height classes of a window. +@immutable +class WindowSizeClass { + /// Creates the size class of a window with the given [width] and [height] + /// classes. + const WindowSizeClass({required this.width, required this.height}); + + /// The size class of a window of the given [size]. + WindowSizeClass.fromSize(Size size) : width = .fromWidth(size.width), height = .fromHeight(size.height); + + /// The size class of the window that encloses [context]. + /// + /// The widget that owns [context] rebuilds whenever the window's size changes. + static WindowSizeClass of(BuildContext context) => .fromSize(MediaQuery.sizeOf(context)); + + /// How much room the window has across. + final WindowWidthClass width; + + /// How much room the window has from top to bottom. + final WindowHeightClass height; +} diff --git a/sample_app/lib/widgets/all_members_sheet.dart b/sample_app/lib/widgets/all_members_sheet.dart index 65694af838..81b5e68589 100644 --- a/sample_app/lib/widgets/all_members_sheet.dart +++ b/sample_app/lib/widgets/all_members_sheet.dart @@ -441,7 +441,7 @@ Future _openDirectChannel(BuildContext context, User user) async { if (StreamSheetRoute.hasParentSheet(context)) { StreamSheetRoute.popSheet(context); } - router.pushNamed( + router.goNamed( Routes.CHANNEL_PAGE.name, pathParameters: Routes.CHANNEL_PAGE.params(channel), ); diff --git a/sample_app/lib/widgets/channel_list.dart b/sample_app/lib/widgets/channel_list.dart index 84d0cccc0d..5cb32eb94d 100644 --- a/sample_app/lib/widgets/channel_list.dart +++ b/sample_app/lib/widgets/channel_list.dart @@ -11,6 +11,7 @@ import '../routes/routes.dart'; import '../utils/app_config.dart'; import 'channel_detail_sheet.dart'; import 'search_text_field.dart'; +import 'split_view.dart'; class ChannelList extends StatefulWidget { const ChannelList({super.key}); @@ -98,7 +99,7 @@ class _ChannelList extends State { if (_scrollController.position.userScrollDirection == ScrollDirection.reverse) { FocusScope.of(context).unfocus(); } - return true; + return false; }, child: NestedScrollView( controller: _scrollController, @@ -119,10 +120,15 @@ class _ChannelList extends State { body: MediaQuery.removePadding( context: context, removeTop: true, - child: switch (_isSearchActive) { - true => _ChannelListSearch(_messageSearchListController), - false => _ChannelListDefault(_channelListController), - }, + // Tiles paint their selection on the nearest Material; this one + // keeps it inside the list instead of behind the search field. + child: Material( + type: MaterialType.transparency, + child: switch (_isSearchActive) { + true => _ChannelListSearch(_messageSearchListController), + false => _ChannelListDefault(_channelListController), + }, + ), ), ), ), @@ -137,6 +143,11 @@ class _ChannelListDefault extends StatelessWidget { @override Widget build(BuildContext context) { + final selectedCid = switch (AdaptiveSplitView.isExpandedOf(context)) { + true => GoRouterState.of(context).pathParameters['cid'], + false => null, + }; + return SlidableAutoCloseBehavior( child: RefreshIndicator( onRefresh: channelListController.refresh, @@ -175,7 +186,7 @@ class _ChannelListDefault extends StatelessWidget { ), ], ), - child: defaultWidget, + child: defaultWidget.copyWith(selected: channel.cid == selectedCid), ); }, onChannelTap: (channel) => _openChannelPage(context, channel), @@ -186,9 +197,9 @@ class _ChannelListDefault extends StatelessWidget { } } -// Pushes the channel page for [channel] via [GoRouter]. -Future _openChannelPage(BuildContext context, Channel channel) { - return GoRouter.of(context).pushNamed( +// Opens the channel page for [channel], replacing any channel already open. +void _openChannelPage(BuildContext context, Channel channel) { + GoRouter.of(context).goNamed( Routes.CHANNEL_PAGE.name, pathParameters: Routes.CHANNEL_PAGE.params(channel), ); @@ -342,7 +353,7 @@ class _ChannelListSearch extends StatelessWidget { if (channel.state == null) { await channel.watch(); } - router.pushNamed( + router.goNamed( Routes.CHANNEL_PAGE.name, pathParameters: Routes.CHANNEL_PAGE.params(channel), queryParameters: Routes.CHANNEL_PAGE.queryParams(message), diff --git a/sample_app/lib/widgets/split_view.dart b/sample_app/lib/widgets/split_view.dart new file mode 100644 index 0000000000..f12db86938 --- /dev/null +++ b/sample_app/lib/widgets/split_view.dart @@ -0,0 +1,656 @@ +import 'dart:math' as math; + +import 'package:collection/collection.dart'; +import 'package:flutter/foundation.dart' show objectRuntimeType; +import 'package:flutter/gestures.dart' show PointerDeviceKind; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +import '../utils/window_size_class.dart'; + +/// A layout that places [primary] and [secondary] side by side, separated by a +/// divider with a handle that people drag to resize the panes. +/// +/// When a fold or hinge divides the window into a left and a right side, each +/// pane fills one side, easing into place as the fold appears or disappears, +/// and the divider can't be dragged. Each pane keeps only the safe-area insets +/// on its outer edges. Assumes it fills the window. +class SplitView extends StatefulWidget { + const SplitView({ + super.key, + required this.primary, + required this.secondary, + this.primaryConstraints = const SplitPaneConstraints(minWidth: 280, initialWidth: 320, maxWidth: 420), + this.secondaryConstraints = const SplitPaneConstraints(minWidth: 360), + this.resizedPrimaryWidth, + this.onPrimaryWidthChanged, + }); + + /// The leading pane. + final Widget primary; + + /// The trailing pane, which fills the width [primary] leaves. + final Widget secondary; + + /// The widths [primary] starts at and can be resized within. + final SplitPaneConstraints primaryConstraints; + + /// The widths [secondary] starts at and can be resized within. + /// + /// Its [SplitPaneConstraints.minWidth] wins over [primaryConstraints] on a + /// window too narrow for both. + final SplitPaneConstraints secondaryConstraints; + + /// The width people already resized [primary] to. + /// + /// When set, the split view starts at this width instead of either pane's + /// [SplitPaneConstraints.initialWidth], so the width can outlive this split + /// view, for example while the window is too narrow for two panes. + final double? resizedPrimaryWidth; + + /// Called with the new width of [primary] each time people resize it. + final ValueChanged? onPrimaryWidthChanged; + + @override + State createState() => _SplitViewState(); +} + +/// The widths a [SplitView] pane starts at and can be resized within. +@immutable +class SplitPaneConstraints { + const SplitPaneConstraints({ + this.minWidth = 0.0, + this.initialWidth, + this.maxWidth = double.infinity, + }) : assert(0 <= minWidth && minWidth <= maxWidth, 'minWidth must be between 0 and maxWidth.'), + assert( + initialWidth == null || (minWidth <= initialWidth && initialWidth <= maxWidth), + 'initialWidth must be between minWidth and maxWidth.', + ); + + /// The narrowest people can make the pane. + final double minWidth; + + /// The width of the pane until people resize it. + /// + /// When both panes have one, the leading pane's wins. When neither does, + /// the leading pane starts at its narrowest. + final double? initialWidth; + + /// The widest people can make the pane. + final double maxWidth; + + @override + bool operator ==(Object other) { + if (identical(other, this)) return true; + return other is SplitPaneConstraints && + other.minWidth == minWidth && + other.initialWidth == initialWidth && + other.maxWidth == maxWidth; + } + + @override + int get hashCode => Object.hash(minWidth, initialWidth, maxWidth); + + @override + String toString() { + return '${objectRuntimeType(this, 'SplitPaneConstraints')}' + '(minWidth: $minWidth, initialWidth: $initialWidth, maxWidth: $maxWidth)'; + } +} + +class _SplitViewState extends State { + // The width of the line between the panes where a fold divides them. + static const _lineWidth = 1.0; + + // The width of the space around the line between the panes, which holds the + // resize handle clear of their content. + static const _spacerWidth = 24.0; + + // How far one keyboard or screen reader step resizes the panes. + static const _resizeStep = 20.0; + + // How long the panes take to settle when a fold starts or stops dividing + // the window, or a keyboard or screen reader step resizes them. + static const _transitionDuration = Duration(milliseconds: 200); + + // The width people resized [SplitView.primary] to, if they have. + late double? _resizedPrimaryWidth = widget.resizedPrimaryWidth; + + // Whether people are dragging the divider, which the panes follow without easing. + var _isDragging = false; + + // The range people can resize [SplitView.primary] within on a window [width] + // wide, where both panes' constraints hold. + ({double min, double max}) _primaryWidthRange(double width) { + final SplitView(:primaryConstraints, :secondaryConstraints) = widget; + final available = width - _spacerWidth; + final max = math.min(primaryConstraints.maxWidth, available - secondaryConstraints.minWidth); + final min = math.max(primaryConstraints.minWidth, available - secondaryConstraints.maxWidth); + return (min: math.min(min, max), max: max); + } + + // The width of [SplitView.primary] on a window [width] wide. + double _primaryWidth(double width) { + final SplitView(:primaryConstraints, :secondaryConstraints) = widget; + final range = _primaryWidthRange(width); + final initialWidth = switch ((primaryConstraints.initialWidth, secondaryConstraints.initialWidth)) { + (final primary?, _) => primary, + (null, final secondary?) => width - _spacerWidth - secondary, + (null, null) => range.min, + }; + return (_resizedPrimaryWidth ?? initialWidth).clamp(range.min, range.max); + } + + void _resizeBy(double delta, double width) { + final range = _primaryWidthRange(width); + final resized = (_primaryWidth(width) + delta).clamp(range.min, range.max); + setState(() => _resizedPrimaryWidth = resized); + widget.onPrimaryWidthChanged?.call(resized); + } + + @override + Widget build(BuildContext context) { + final mediaQuery = MediaQuery.of(context); + final width = mediaQuery.size.width; + final isLtr = Directionality.of(context) == TextDirection.ltr; + + final fold = _verticalFold(mediaQuery); + final range = _primaryWidthRange(width); + final primaryWidth = switch (fold) { + final fold? => isLtr ? fold.left : width - fold.right, + null => _primaryWidth(width), + }; + final dividerWidth = switch (fold) { + final fold? => math.max(fold.width, _lineWidth), + null => _spacerWidth, + }; + + // Builds the same tree with or without a fold, so the panes ease between the two. + return Stack( + fit: .expand, + children: [ + _buildAnimatedPanes(mediaQuery, primaryWidth, dividerWidth), + if (fold == null) + PositionedDirectional( + top: 0, + bottom: 0, + start: primaryWidth + (_spacerWidth - _SplitViewDivider.hitWidth) / 2, + width: _SplitViewDivider.hitWidth, + child: _SplitViewDivider( + primaryWidth: primaryWidth, + minPrimaryWidth: range.min, + maxPrimaryWidth: range.max, + windowWidth: width, + step: _resizeStep, + onResizeStart: () => setState(() => _isDragging = true), + onResize: (delta) => _resizeBy(delta, width), + onResizeEnd: () => setState(() => _isDragging = false), + onStep: (delta) => _resizeBy(delta, width), + ), + ), + ], + ); + } + + Widget _buildAnimatedPanes(MediaQueryData mediaQuery, double primaryWidth, double dividerWidth) { + final animate = !_isDragging && !mediaQuery.disableAnimations; + + return TweenAnimationBuilder( + tween: Tween(end: primaryWidth), + duration: animate ? _transitionDuration : Duration.zero, + curve: Curves.easeInOut, + builder: (context, primaryPaneWidth, _) => _buildPanes( + context, + mediaQuery: mediaQuery, + primaryPaneWidth: primaryPaneWidth, + dividerWidth: dividerWidth, + ), + ); + } + + Widget _buildPanes( + BuildContext context, { + required MediaQueryData mediaQuery, + required double primaryPaneWidth, + required double dividerWidth, + }) { + final colorScheme = context.streamColorScheme; + final Size(:width, :height) = mediaQuery.size; + final isLtr = Directionality.of(context) == TextDirection.ltr; + + // Each pane's area on the window, which the insets and folds are relative to. + final secondaryPaneWidth = width - primaryPaneWidth - dividerWidth; + final primaryPane = Rect.fromLTWH(isLtr ? 0 : width - primaryPaneWidth, 0, primaryPaneWidth, height); + final secondaryPane = Rect.fromLTWH(isLtr ? width - secondaryPaneWidth : 0, 0, secondaryPaneWidth, height); + + // Paints behind the panes too, so no gap shows where their edges round to + // different pixels. + return ColoredBox( + color: colorScheme.backgroundApp, + child: Row( + children: [ + SizedBox( + width: primaryPaneWidth, + child: MediaQuery( + data: mediaQuery.removeDisplayFeatures(primaryPane), + child: widget.primary, + ), + ), + VerticalDivider( + width: dividerWidth, + thickness: _lineWidth, + color: colorScheme.borderSubtle, + ), + Expanded( + child: MediaQuery( + data: mediaQuery.removeDisplayFeatures(secondaryPane), + child: widget.secondary, + ), + ), + ], + ), + ); + } +} + +// The area around the line between a split view's panes that people drag, or +// step with the arrow keys or a screen reader, to resize them. +class _SplitViewDivider extends StatefulWidget { + const _SplitViewDivider({ + required this.primaryWidth, + required this.minPrimaryWidth, + required this.maxPrimaryWidth, + required this.windowWidth, + required this.step, + required this.onResizeStart, + required this.onResize, + required this.onResizeEnd, + required this.onStep, + }); + + // The width of the area, centered on the gap, that responds to a mouse or + // trackpad drag along its full height. + static const hitWidth = 44.0; + + // The handle at the middle of the gap, the only part that responds to touch, + // so swipes near the gap reach the panes beneath it. It grows while held. + static const _handleSize = Size(4, 48); + static const _heldHandleSize = Size(12, 52); + static const _handleHitSize = Size(hitWidth, 52); + + final double primaryWidth; + final double minPrimaryWidth; + final double maxPrimaryWidth; + final double windowWidth; + final double step; + + final VoidCallback onResizeStart; + + // Called with how far the primary pane grows, negative when it shrinks. + final ValueChanged onResize; + final VoidCallback onResizeEnd; + + // Called with how far one step grows the primary pane, negative when it shrinks. + final ValueChanged onStep; + + @override + State<_SplitViewDivider> createState() => _SplitViewDividerState(); +} + +class _SplitViewDividerState extends State<_SplitViewDivider> { + var _isFocused = false; + var _isHeld = false; + + void _setHeld(bool isHeld) { + if (_isHeld != isHeld) setState(() => _isHeld = isHeld); + } + + bool get _canGrow => widget.primaryWidth < widget.maxPrimaryWidth; + bool get _canShrink => widget.primaryWidth > widget.minPrimaryWidth; + + String _describe(double primaryWidth) => '${(primaryWidth / widget.windowWidth * 100).round()}%'; + + KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event, bool isLtr) { + if (event is KeyUpEvent) return KeyEventResult.ignored; + + final direction = switch (event.logicalKey) { + LogicalKeyboardKey.arrowRight => isLtr ? 1 : -1, + LogicalKeyboardKey.arrowLeft => isLtr ? -1 : 1, + _ => null, + }; + if (direction == null) return KeyEventResult.ignored; + + widget.onStep(direction * widget.step); + return KeyEventResult.handled; + } + + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + final isLtr = Directionality.of(context) == TextDirection.ltr; + final direction = isLtr ? 1.0 : -1.0; + + Widget dragToResize({ + required HitTestBehavior behavior, + Set? supportedDevices, + required Widget child, + }) { + return GestureDetector( + behavior: behavior, + supportedDevices: supportedDevices, + onTapDown: (_) => _setHeld(true), + onTapUp: (_) => _setHeld(false), + onTapCancel: () => _setHeld(false), + onHorizontalDragStart: (_) { + _setHeld(true); + widget.onResizeStart(); + }, + onHorizontalDragUpdate: (details) => widget.onResize(details.primaryDelta! * direction), + onHorizontalDragEnd: (_) { + _setHeld(false); + widget.onResizeEnd(); + }, + onHorizontalDragCancel: () { + _setHeld(false); + widget.onResizeEnd(); + }, + child: child, + ); + } + + final handleSize = _isHeld ? _SplitViewDivider._heldHandleSize : _SplitViewDivider._handleSize; + + final grown = math.min(widget.primaryWidth + widget.step, widget.maxPrimaryWidth); + final shrunk = math.max(widget.primaryWidth - widget.step, widget.minPrimaryWidth); + + return Semantics( + slider: true, + label: 'Channel list width', + value: _describe(widget.primaryWidth), + increasedValue: _canGrow ? _describe(grown) : null, + decreasedValue: _canShrink ? _describe(shrunk) : null, + onIncrease: _canGrow ? () => widget.onStep(widget.step) : null, + onDecrease: _canShrink ? () => widget.onStep(-widget.step) : null, + child: Focus( + onFocusChange: (focused) => setState(() => _isFocused = focused), + onKeyEvent: (node, event) => _handleKeyEvent(node, event, isLtr), + child: MouseRegion( + cursor: SystemMouseCursors.resizeColumn, + // Leaves taps near the line to the panes beneath it. + hitTestBehavior: HitTestBehavior.translucent, + child: dragToResize( + behavior: HitTestBehavior.translucent, + supportedDevices: const {PointerDeviceKind.mouse, PointerDeviceKind.trackpad}, + child: Center( + child: dragToResize( + behavior: HitTestBehavior.opaque, + child: SizedBox.fromSize( + size: _SplitViewDivider._handleHitSize, + child: Center( + child: AnimatedContainer( + duration: kThemeChangeDuration, + width: handleSize.width, + height: handleSize.height, + decoration: ShapeDecoration( + // Marks the handle while it has keyboard focus. + color: _isFocused ? colorScheme.borderFocus : colorScheme.accentNeutral, + shape: RoundedSuperellipseBorder(borderRadius: .all(context.streamRadius.max)), + ), + ), + ), + ), + ), + ), + ), + ), + ), + ); + } +} + +// The fold or hinge that divides the window into a left and a right side, if any. +Rect? _verticalFold(MediaQueryData mediaQuery) { + final Size(:width, :height) = mediaQuery.size; + return DisplayFeatureSubScreen.avoidBounds(mediaQuery).firstWhereOrNull( + (it) => it.top <= 0 && it.bottom >= height && it.left > 0 && it.right < width, + ); +} + +/// A split view that shows [primary] beside [secondary] on a +/// [WindowWidthClass.expanded] window, and only [secondary] otherwise. +/// +/// Both panes also need a window that is at least [WindowHeightClass.medium], +/// so a phone in landscape shows one pane at a time. +/// +/// [secondary] is the navigator for the trailing pane, and its root page is an +/// [AdaptiveSplitViewRoot]. While only [secondary] is shown, that root page +/// shows [primary], so both panes keep their state as the window changes size. Its +/// pages are [AdaptiveSplitViewPage]s, which swap in place beside [primary]. +/// +/// ```dart +/// ShellRoute( +/// builder: (context, state, child) { +/// return AdaptiveSplitView(primary: const ChannelListPage(), secondary: child); +/// }, +/// routes: [ +/// GoRoute( +/// path: '/channels', +/// pageBuilder: (context, state) { +/// return AdaptiveSplitViewPage( +/// key: state.pageKey, +/// child: const AdaptiveSplitViewRoot(placeholder: ChannelPlaceholderPage()), +/// ); +/// }, +/// routes: [/* The routes that open in the trailing pane. */], +/// ), +/// ], +/// ) +/// ``` +class AdaptiveSplitView extends StatefulWidget { + const AdaptiveSplitView({ + super.key, + required this.primary, + required this.secondary, + this.drawer, + }); + + /// The leading pane, such as a list. + final Widget primary; + + /// The navigator for the trailing pane. + final Widget secondary; + + /// A panel that slides in over both panes while they are shown side by side. + /// + /// Typically a [Drawer], opened with [openDrawerOf]. While only the navigator + /// is shown, the pages in it can host a drawer of their own. + final Widget? drawer; + + /// Whether the nearest [AdaptiveSplitView] shows both panes. + /// + /// Returns `false` when there is no [AdaptiveSplitView] above [context]. The + /// widget that owns [context] rebuilds only when the split view expands or + /// collapses. + static bool isExpandedOf(BuildContext context) { + final scope = context.dependOnInheritedWidgetOfExactType<_AdaptiveSplitViewScope>(); + return scope?.isExpanded ?? false; + } + + /// Opens the [drawer] of the nearest [AdaptiveSplitView] above [context]. + /// + /// Does nothing when that split view shows one pane or has no [drawer]. + static void openDrawerOf(BuildContext context) { + final state = context.findAncestorStateOfType<_AdaptiveSplitViewState>(); + state?._drawerKey.currentState?.open(); + } + + @override + State createState() => _AdaptiveSplitViewState(); +} + +class _AdaptiveSplitViewState extends State { + // Moves the primary pane between the leading pane and the root page. + final _primaryKey = GlobalKey(debugLabel: 'AdaptiveSplitView.primary'); + + // Opens and closes the drawer that spans both panes. + final _drawerKey = GlobalKey(debugLabel: 'AdaptiveSplitView.drawer'); + + // The width people resized the primary pane to, kept here so it outlives the + // split view while the window shows one pane. + double? _primaryWidth; + + // Whether the drawer is open. + var _isDrawerOpen = false; + + @override + Widget build(BuildContext context) { + final sizeClass = WindowSizeClass.of(context); + final isExpanded = sizeClass.width.isAtLeast(.expanded) && sizeClass.height.isAtLeast(.medium); + + return _AdaptiveSplitViewScope( + isExpanded: isExpanded, + primaryKey: _primaryKey, + primary: widget.primary, + child: switch (isExpanded) { + true => _buildSplit(), + false => widget.secondary, + }, + ); + } + + Widget _buildSplit() { + final split = SplitView( + primary: KeyedSubtree(key: _primaryKey, child: widget.primary), + secondary: widget.secondary, + resizedPrimaryWidth: _primaryWidth, + onPrimaryWidthChanged: (width) => _primaryWidth = width, + ); + + final drawer = widget.drawer; + if (drawer == null) return split; + + final isLtr = Directionality.of(context) == TextDirection.ltr; + + return Stack( + fit: .expand, + children: [ + split, + // The drawer only reaches the leading edge, so it keeps only that edge's inset. + MediaQuery.removePadding( + context: context, + removeLeft: !isLtr, + removeRight: isLtr, + // Back closes the drawer here, ahead of the pane navigators below it. + child: BackButtonListener( + onBackButtonPressed: () async { + if (!_isDrawerOpen) return false; + _drawerKey.currentState?.close(); + return true; + }, + child: DrawerController( + key: _drawerKey, + alignment: .start, + drawerCallback: (isOpen) => _isDrawerOpen = isOpen, + child: drawer, + ), + ), + ), + ], + ); + } +} + +class _AdaptiveSplitViewScope extends InheritedWidget { + const _AdaptiveSplitViewScope({ + required this.isExpanded, + required this.primaryKey, + required this.primary, + required super.child, + }); + + final bool isExpanded; + final GlobalKey primaryKey; + final Widget primary; + + static _AdaptiveSplitViewScope of(BuildContext context) { + final scope = context.dependOnInheritedWidgetOfExactType<_AdaptiveSplitViewScope>(); + assert(scope != null, 'No AdaptiveSplitView found in context.'); + return scope!; + } + + @override + bool updateShouldNotify(_AdaptiveSplitViewScope oldWidget) { + return isExpanded != oldWidget.isExpanded || primary != oldWidget.primary; + } +} + +/// The root page of an [AdaptiveSplitView]'s navigator. +/// +/// Shows the split view's primary pane while only the navigator is shown, and +/// [placeholder] while both panes are shown. +class AdaptiveSplitViewRoot extends StatelessWidget { + const AdaptiveSplitViewRoot({ + super.key, + required this.placeholder, + }); + + /// The widget that fills the trailing pane while nothing is open in it. + final Widget placeholder; + + @override + Widget build(BuildContext context) { + final scope = _AdaptiveSplitViewScope.of(context); + if (scope.isExpanded) return placeholder; + + return KeyedSubtree(key: scope.primaryKey, child: scope.primary); + } +} + +/// A page for the navigator of an [AdaptiveSplitView]'s trailing pane. +/// +/// Swaps in without a transition while both panes are shown, and uses the +/// platform's page transition when only the navigator is. +class AdaptiveSplitViewPage extends Page { + const AdaptiveSplitViewPage({ + super.key, + super.name, + required this.child, + }); + + /// The content of the page. + final Widget child; + + @override + Route createRoute(BuildContext context) => _AdaptiveSplitViewPageRoute(page: this); +} + +class _AdaptiveSplitViewPageRoute extends PageRoute with MaterialRouteTransitionMixin { + _AdaptiveSplitViewPageRoute({required AdaptiveSplitViewPage page}) : super(settings: page); + + AdaptiveSplitViewPage get _page => settings as AdaptiveSplitViewPage; + + @override + Widget buildContent(BuildContext context) => _page.child; + + @override + bool get maintainState => true; + + @override + bool get fullscreenDialog => false; + + @override + String get debugLabel => '${super.debugLabel}(${_page.name})'; + + @override + Widget buildTransitions( + BuildContext context, + Animation animation, + Animation secondaryAnimation, + Widget child, + ) { + if (AdaptiveSplitView.isExpandedOf(context)) return child; + return super.buildTransitions(context, animation, secondaryAnimation, child); + } +} diff --git a/sample_app/lib/widgets/stream_draft_list_tile.dart b/sample_app/lib/widgets/stream_draft_list_tile.dart index d782c61bf1..5918acdedd 100644 --- a/sample_app/lib/widgets/stream_draft_list_tile.dart +++ b/sample_app/lib/widgets/stream_draft_list_tile.dart @@ -13,6 +13,7 @@ class StreamDraftListTile extends StatelessWidget { this.currentUser, this.onTap, this.onLongPress, + this.selected = false, }); /// The draft to display. @@ -27,10 +28,36 @@ class StreamDraftListTile extends StatelessWidget { /// Called when the user long-presses on this list tile. final GestureLongPressCallback? onLongPress; + /// Whether this tile is highlighted as the open draft. + final bool selected; + + /// Creates a copy of this tile but with the given fields replaced with + /// the new values. + StreamDraftListTile copyWith({ + Key? key, + Draft? draft, + User? currentUser, + GestureTapCallback? onTap, + GestureLongPressCallback? onLongPress, + bool? selected, + }) { + return StreamDraftListTile( + key: key ?? this.key, + draft: draft ?? this.draft, + currentUser: currentUser ?? this.currentUser, + onTap: onTap ?? this.onTap, + onLongPress: onLongPress ?? this.onLongPress, + selected: selected ?? this.selected, + ); + } + @override Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + final background = colorScheme.backgroundElevation1; + return Material( - color: context.streamColorScheme.backgroundElevation1, + color: selected ? Color.alphaBlend(colorScheme.backgroundSelected, background) : background, child: InkWell( onTap: onTap, onLongPress: onLongPress,