-
Notifications
You must be signed in to change notification settings - Fork 3.5k
Add tablet workspace layout #4274
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 20 commits
a307669
4b98651
e0e4b7f
94b052f
e0d7e62
09d1e24
ed1c380
f552a95
21abc53
64a589b
b59dc05
8508c11
b6dd0f5
11a4837
2ea7367
9974e9b
53d3220
389e8df
7ca134f
737d3d1
97294b4
750cf74
c78d6cd
621ce0e
be7933d
2003db3
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,6 +1,4 @@ | ||
| import Flutter | ||
| import UIKit | ||
|
|
||
| class SceneDelegate: FlutterSceneDelegate { | ||
|
|
||
| } | ||
| class SceneDelegate: FlutterSceneDelegate {} |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -27,6 +27,7 @@ import '../profile/user_cache_provider.dart'; | |
| import '../profile/user_profile.dart'; | ||
| import 'activity_provider.dart'; | ||
| import 'compose_drafts_provider.dart'; | ||
| import 'feed_item.dart'; | ||
| import 'inbox_item.dart'; | ||
| import 'inbox_local_state_provider.dart'; | ||
| import 'inbox_read_state.dart'; | ||
|
|
@@ -37,6 +38,10 @@ part 'activity_page/inbox_row.dart'; | |
| part 'activity_page/lists.dart'; | ||
| part 'activity_page/status_views.dart'; | ||
|
|
||
| const _wideInboxBreakpoint = 1000.0; | ||
| const _wideInboxMinListWidth = 300.0; | ||
| const _wideInboxMaxListWidth = 400.0; | ||
|
|
||
| EdgeInsets _activityScrollPadding( | ||
| BuildContext context, { | ||
| double horizontal = 0, | ||
|
|
@@ -66,6 +71,14 @@ class ActivityPage extends HookConsumerWidget { | |
| final channelsAsync = ref.watch(channelsProvider); | ||
| final filter = useState(InboxFilter.all); | ||
| final unreadOnly = useState(false); | ||
| final selectedItemId = useState<String?>(null); | ||
| // Retain the event selected before its row is marked read. A grouped row | ||
| // otherwise recomputes its deep link after the read marker changes and | ||
| // opens the latest event instead of the oldest unread one the user chose. | ||
| final selectedItemTarget = useState<FeedItem?>(null); | ||
| final selectedItemForDetail = useState<InboxItem?>(null); | ||
| final isWideInbox = | ||
| MediaQuery.sizeOf(context).width >= _wideInboxBreakpoint; | ||
| final headerTitleStyle = context.textTheme.titleMedium?.copyWith( | ||
| fontSize: 22, | ||
| fontWeight: FontWeight.w600, | ||
|
|
@@ -99,6 +112,30 @@ class ActivityPage extends HookConsumerWidget { | |
| (!unreadOnly.value || !isDone(item))) | ||
| item, | ||
| ]; | ||
| final visibleItemIdsKey = visibleItems | ||
| .map((item) => item.id) | ||
| .join('\u0000'); | ||
| useEffect(() { | ||
| if (!isWideInbox || visibleItems.isEmpty) return null; | ||
| final hasSelectedItem = visibleItems.any( | ||
| (item) => item.conversationId == selectedItemId.value, | ||
| ); | ||
| final retainsSelectedDetail = | ||
| unreadOnly.value && | ||
|
klopez4212 marked this conversation as resolved.
|
||
| selectedItemForDetail.value?.conversationId == selectedItemId.value; | ||
| if (!hasSelectedItem && !retainsSelectedDetail) { | ||
|
klopez4212 marked this conversation as resolved.
|
||
| selectedItemId.value = visibleItems.first.conversationId; | ||
| selectedItemTarget.value = null; | ||
| selectedItemForDetail.value = null; | ||
| } | ||
| return null; | ||
| }, [isWideInbox, visibleItemIdsKey, filter.value, unreadOnly.value]); | ||
| final selectedItem = | ||
| visibleItems.cast<InboxItem?>().firstWhere( | ||
| (item) => item?.conversationId == selectedItemId.value, | ||
| orElse: () => null, | ||
| ) ?? | ||
| (unreadOnly.value ? selectedItemForDetail.value : null); | ||
|
|
||
| // Preload sender profiles for visible rows. | ||
| final preloadPubkeys = { | ||
|
|
@@ -176,6 +213,14 @@ class ActivityPage extends HookConsumerWidget { | |
| ? null | ||
| : thread.parentId; | ||
|
|
||
| if (isWideInbox) { | ||
| selectedItemId.value = item.conversationId; | ||
| selectedItemTarget.value = target; | ||
| selectedItemForDetail.value = item; | ||
| markItemRead(item); | ||
|
klopez4212 marked this conversation as resolved.
|
||
| return; | ||
|
klopez4212 marked this conversation as resolved.
|
||
| } | ||
|
|
||
| Navigator.of(context).push( | ||
| MaterialPageRoute<void>( | ||
| builder: (_) => ChannelDetailPage( | ||
|
|
@@ -292,6 +337,9 @@ class ActivityPage extends HookConsumerWidget { | |
| channel: channel, | ||
| currentPubkey: myPk, | ||
| isDone: isDone(item), | ||
| selected: | ||
| isWideInbox && | ||
| item.conversationId == selectedItemId.value, | ||
| onTap: () => openItem(item), | ||
| onMarkRead: () => markItemRead(item), | ||
| onMarkUnread: () => markItemUnread(item), | ||
|
|
@@ -303,24 +351,26 @@ class ActivityPage extends HookConsumerWidget { | |
| ); | ||
| } | ||
|
|
||
| return FrostedScaffold( | ||
| Widget inboxListPane(String title) => FrostedScaffold( | ||
| backgroundColor: Colors.transparent, | ||
| appBar: FrostedAppBar( | ||
| gradient: context.appColors.topSectionGradient, | ||
| automaticallyImplyLeading: false, | ||
| title: const Text('Activity'), | ||
| title: Text(title), | ||
| titleStyle: headerTitleStyle, | ||
| actions: [ | ||
| _FilterMenuButton( | ||
| filter: filter.value, | ||
| dueReminderCount: dueReminderCount, | ||
| draftCount: drafts.length, | ||
| onChanged: (f) => filter.value = f, | ||
| onChanged: (nextFilter) { | ||
| filter.value = nextFilter; | ||
| }, | ||
| ), | ||
| _InboxOptionsButton( | ||
| unreadOnly: unreadOnly.value, | ||
| unreadCount: unreadVisibleCount, | ||
| onUnreadOnlyChanged: (v) => unreadOnly.value = v, | ||
| onUnreadOnlyChanged: (value) => unreadOnly.value = value, | ||
| onMarkAllRead: () { | ||
| for (final item in visibleItems) { | ||
| if (!isDone(item)) markItemRead(item); | ||
|
|
@@ -341,5 +391,131 @@ class ActivityPage extends HookConsumerWidget { | |
| ), | ||
| ), | ||
| ); | ||
|
|
||
| if (isWideInbox) { | ||
| FeedItem? detailTarget(InboxItem? inboxItem) { | ||
| if (inboxItem == null) return null; | ||
| return inboxItem.deepLinkTarget( | ||
| resolveInboxItemReadAt(inboxItem, markerOf: markerOf), | ||
| ); | ||
| } | ||
|
|
||
| final selectedTarget = | ||
| selectedItemTarget.value ?? detailTarget(selectedItem); | ||
| final selectedChannelId = selectedTarget?.channelId; | ||
| final selectedChannel = selectedChannelId == null | ||
| ? null | ||
| : channelById[selectedChannelId]; | ||
| final selectedThread = selectedTarget == null | ||
| ? null | ||
| : isBroadcastReply(selectedTarget.tags) | ||
| ? null | ||
| : threadReferenceOf(selectedTarget.tags).parentId; | ||
|
|
||
| return LayoutBuilder( | ||
| builder: (context, constraints) { | ||
| final listWidth = (constraints.maxWidth / 3) | ||
| .clamp(_wideInboxMinListWidth, _wideInboxMaxListWidth) | ||
| .toDouble(); | ||
| return Row( | ||
| children: [ | ||
| SizedBox( | ||
| key: const Key('wide-activity-inbox-list'), | ||
| width: listWidth, | ||
| child: inboxListPane('Inbox'), | ||
| ), | ||
| VerticalDivider(width: 1, color: context.colors.outlineVariant), | ||
| Expanded( | ||
| child: _WideActivityDetail( | ||
| item: selectedItem, | ||
| channel: selectedChannel, | ||
| initialMessageId: selectedTarget?.id, | ||
| initialThreadRootId: selectedThread, | ||
| ), | ||
| ), | ||
| ], | ||
| ); | ||
| }, | ||
| ); | ||
| } | ||
|
|
||
| return inboxListPane('Activity'); | ||
| } | ||
| } | ||
|
|
||
| class _WideActivityDetail extends HookWidget { | ||
| final InboxItem? item; | ||
| final Channel? channel; | ||
| final String? initialMessageId; | ||
| final String? initialThreadRootId; | ||
|
|
||
| const _WideActivityDetail({ | ||
| required this.item, | ||
| required this.channel, | ||
| required this.initialMessageId, | ||
| required this.initialThreadRootId, | ||
| }); | ||
|
|
||
| @override | ||
| Widget build(BuildContext context) { | ||
| if (item == null || channel == null || initialMessageId == null) { | ||
| return const _WideActivityEmptyDetail(); | ||
| } | ||
|
|
||
| final navigatorKey = useMemoized(GlobalKey<NavigatorState>.new, [ | ||
| item!.conversationId, | ||
| ]); | ||
|
Comment on lines
+503
to
+505
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a grouped conversation is already selected and a newer unread event changes its deep-link target, tapping that row updates Useful? React with 👍 / 👎. |
||
| return NavigatorPopHandler( | ||
| key: ValueKey('wide-activity-detail-${item!.conversationId}'), | ||
| onPopWithResult: (_) => navigatorKey.currentState?.maybePop(), | ||
| child: Navigator( | ||
| key: navigatorKey, | ||
| onGenerateRoute: (_) => MaterialPageRoute<void>( | ||
| builder: (_) => ChannelDetailPage( | ||
| channel: channel!, | ||
| initialMessageId: initialMessageId, | ||
| initialThreadRootId: initialThreadRootId, | ||
| ), | ||
|
klopez4212 marked this conversation as resolved.
|
||
| ), | ||
| ), | ||
| ); | ||
| } | ||
| } | ||
|
|
||
| class _WideActivityEmptyDetail extends StatelessWidget { | ||
| const _WideActivityEmptyDetail(); | ||
|
|
||
| @override | ||
| Widget build(BuildContext context) { | ||
| return ColoredBox( | ||
| color: context.colors.surface, | ||
| child: Center( | ||
| child: Column( | ||
| mainAxisSize: MainAxisSize.min, | ||
| children: [ | ||
| Icon( | ||
| LucideIcons.inbox, | ||
| color: context.colors.onSurfaceVariant, | ||
| size: 32, | ||
| ), | ||
| const SizedBox(height: Grid.sm), | ||
| Text( | ||
| 'Select an inbox item', | ||
| style: context.textTheme.titleMedium?.copyWith( | ||
| color: context.colors.onSurface, | ||
| fontWeight: FontWeight.w600, | ||
| ), | ||
| ), | ||
| const SizedBox(height: Grid.xxs), | ||
| Text( | ||
| 'Its conversation will open here.', | ||
| style: context.textTheme.bodyMedium?.copyWith( | ||
| color: context.colors.onSurfaceVariant, | ||
| ), | ||
| ), | ||
| ], | ||
| ), | ||
| ), | ||
| ); | ||
| } | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.