diff --git a/lib/widgets/app.dart b/lib/widgets/app.dart index a7fe91bee9..351108b80b 100644 --- a/lib/widgets/app.dart +++ b/lib/widgets/app.dart @@ -8,6 +8,7 @@ import 'package:flutter_gen/gen_l10n/zulip_localizations.dart'; import '../model/localizations.dart'; import '../model/narrow.dart'; import 'about_zulip.dart'; +import 'inbox.dart'; import 'login.dart'; import 'message_list.dart'; import 'page.dart'; @@ -254,6 +255,11 @@ class HomePage extends StatelessWidget { narrow: const AllMessagesNarrow())), child: const Text("All messages")), const SizedBox(height: 16), + ElevatedButton( + onPressed: () => Navigator.push(context, + InboxPage.buildRoute(context: context)), + child: const Text("Inbox")), // TODO(i18n) + const SizedBox(height: 16), ElevatedButton( onPressed: () => Navigator.push(context, RecentDmConversationsPage.buildRoute(context: context)), diff --git a/lib/widgets/inbox.dart b/lib/widgets/inbox.dart new file mode 100644 index 0000000000..e7fef7c6fe --- /dev/null +++ b/lib/widgets/inbox.dart @@ -0,0 +1,490 @@ +import 'package:flutter/material.dart'; + +import '../api/model/model.dart'; +import '../model/narrow.dart'; +import '../model/recent_dm_conversations.dart'; +import '../model/unreads.dart'; +import 'icons.dart'; +import 'message_list.dart'; +import 'page.dart'; +import 'sticky_header.dart'; +import 'store.dart'; +import 'text.dart'; +import 'unread_count_badge.dart'; + +class InboxPage extends StatefulWidget { + const InboxPage({super.key}); + + static Route buildRoute({required BuildContext context}) { + return MaterialAccountWidgetRoute(context: context, + page: const InboxPage()); + } + + @override + State createState() => _InboxPageState(); +} + +class _InboxPageState extends State with PerAccountStoreAwareStateMixin { + Unreads? unreadsModel; + RecentDmConversationsView? recentDmConversationsModel; + + get allDmsCollapsed => _allDmsCollapsed; + bool _allDmsCollapsed = false; + set allDmsCollapsed(value) { + setState(() { + _allDmsCollapsed = value; + }); + } + + get collapsedStreamIds => _collapsedStreamIds; + final Set _collapsedStreamIds = {}; + void collapseStream(int streamId) { + setState(() { + _collapsedStreamIds.add(streamId); + }); + } + void uncollapseStream(int streamId) { + setState(() { + _collapsedStreamIds.remove(streamId); + }); + } + + @override + void onNewStore() { + final newStore = PerAccountStoreWidget.of(context); + unreadsModel?.removeListener(_modelChanged); + unreadsModel = newStore.unreads..addListener(_modelChanged); + recentDmConversationsModel?.removeListener(_modelChanged); + recentDmConversationsModel = newStore.recentDmConversationsView + ..addListener(_modelChanged); + } + + @override + void dispose() { + unreadsModel?.removeListener(_modelChanged); + recentDmConversationsModel?.removeListener(_modelChanged); + super.dispose(); + } + + void _modelChanged() { + setState(() { + // Much of the state lives in [unreadsModel] and + // [recentDmConversationsModel]. + // This method was called because one of those just changed. + // + // We also update some state that lives locally: we reset a collapsible + // row's collapsed state when it's cleared of unreads. + // TODO(perf) handle those updates efficiently + collapsedStreamIds.removeWhere((streamId) => + !unreadsModel!.streams.containsKey(streamId)); + if (unreadsModel!.dms.isEmpty) { + allDmsCollapsed = false; + } + }); + } + + @override + Widget build(BuildContext context) { + final store = PerAccountStoreWidget.of(context); + + // TODO(perf) make an incrementally-updated view-model for InboxPage + final sections = <_InboxSectionData>[]; + + // TODO efficiently include DM conversations that aren't recent enough + // to appear in recentDmConversationsView, but still have unreads in + // unreadsModel. + final dmItems = <(DmNarrow, int)>[]; + int allDmsCount = 0; + for (final dmNarrow in recentDmConversationsModel!.sorted) { + final countInNarrow = unreadsModel!.countInDmNarrow(dmNarrow); + if (countInNarrow == 0) { + continue; + } + dmItems.add((dmNarrow, countInNarrow)); + allDmsCount += countInNarrow; + } + if (allDmsCount > 0) { + sections.add(_AllDmsSectionData(allDmsCount, dmItems)); + } + + // TODO sorting (of streams; and within a stream, of topics). + // For a first pass, just needs checking that performance stays reasonable. + for (final MapEntry(key: streamId, value: topics) in unreadsModel!.streams.entries) { + if (!store.subscriptions.containsKey(streamId)) { + // Filter out any straggling unreads in unsubscribed streams. + // There won't normally be any, but it happens with certain infrequent + // state changes, typically for less than a few hundred milliseconds. + // See [Unreads]. + // + // Also, we want to depend on the subscription data for things like + // the stream color. + continue; + } + final topicItems = <(String, int)>[]; + int countInStream = 0; + for (final MapEntry(key: topic, value: messageIds) in topics.entries) { + final countInTopic = messageIds.length; + topicItems.add((topic, countInTopic)); + countInStream += countInTopic; + } + if (countInStream == 0) { + continue; + } + sections.add(_StreamSectionData(streamId, countInStream, topicItems)); + } + + // TODO(#346) Filter out muted messages + + return Scaffold( + appBar: AppBar(title: const Text('Inbox')), + body: StickyHeaderListView.builder( + itemCount: sections.length, + itemBuilder: (context, index) { + final section = sections[index]; + switch (section) { + case _AllDmsSectionData(): + return _AllDmsSection( + data: section, + collapsed: allDmsCollapsed, + pageState: this, + ); + case _StreamSectionData(:var streamId): + final collapsed = collapsedStreamIds.contains(streamId); + return _StreamSection(data: section, collapsed: collapsed, pageState: this); + } + })); + } +} + +sealed class _InboxSectionData { + const _InboxSectionData(); +} + +class _AllDmsSectionData extends _InboxSectionData { + final int count; + final List<(DmNarrow, int)> items; + + const _AllDmsSectionData(this.count, this.items); +} + +class _StreamSectionData extends _InboxSectionData { + final int streamId; + final int count; + final List<(String, int)> items; + + const _StreamSectionData(this.streamId, this.count, this.items); +} + +abstract class _HeaderItem extends StatelessWidget { + final bool collapsed; + final _InboxPageState pageState; + final int count; + + const _HeaderItem({ + required this.collapsed, + required this.pageState, + required this.count, + }); + + String get title; + IconData get icon; + Color get iconColor; + Color get uncollapsedBackgroundColor; + Color? get unreadCountBadgeBackgroundColor; + + void Function() get onCollapseButtonTap; + void Function() get onRowTap; + + @override + Widget build(BuildContext context) { + return Material( + color: collapsed ? Colors.white : uncollapsedBackgroundColor, + child: InkWell( + onTap: onRowTap, + // TODO min-height 48px, like other touch targets? + child: Row(crossAxisAlignment: CrossAxisAlignment.center, children: [ + Padding(padding: const EdgeInsets.all(10), + // TODO use onCollapseButtonTap. Probably requires a 48px square + // touch target, which is in tension with the Figma, which gives + // these header rows 40px min height. + child: Icon(size: 20, color: const Color(0x7F1D2E48), + collapsed ? ZulipIcons.arrow_right : ZulipIcons.arrow_down)), + Icon(size: 18, color: iconColor, + icon), + const SizedBox(width: 5), + Expanded(child: Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Text( + style: const TextStyle( + fontFamily: 'Source Sans 3', + fontSize: 17, + height: (20 / 17), + color: Color(0xFF222222), + ).merge(weightVariableTextStyle(context, wght: 600, wghtIfPlatformRequestsBold: 900)), + maxLines: 1, + overflow: TextOverflow.ellipsis, + title))), + const SizedBox(width: 12), + // TODO(#384) for streams, show @-mention indicator when it applies + Padding(padding: const EdgeInsetsDirectional.only(end: 16), + child: UnreadCountBadge(backgroundColor: unreadCountBadgeBackgroundColor, bold: true, + count: count)), + ])), + ); + } +} + +class _AllDmsHeaderItem extends _HeaderItem { + const _AllDmsHeaderItem({ + required super.collapsed, + required super.pageState, + required super.count, + }); + + @override get title => 'Direct messages'; // TODO(i18n) + @override get icon => ZulipIcons.user; + @override get iconColor => const Color(0xFF222222); + @override get uncollapsedBackgroundColor => const Color(0xFFF3F0E7); + @override get unreadCountBadgeBackgroundColor => null; + + @override get onCollapseButtonTap => () { + pageState.allDmsCollapsed = !collapsed; + }; + @override get onRowTap => onCollapseButtonTap; // TODO? +} + +class _AllDmsSection extends StatelessWidget { + const _AllDmsSection({ + required this.data, + required this.collapsed, + required this.pageState, + }); + + final _AllDmsSectionData data; + final bool collapsed; + final _InboxPageState pageState; + + @override + Widget build(BuildContext context) { + final header = _AllDmsHeaderItem( + count: data.count, + collapsed: collapsed, + pageState: pageState, + ); + return StickyHeaderItem( + header: header, + child: Column(children: [ + header, + if (!collapsed) ...data.items.map((item) { + final (narrow, count) = item; + return _DmItem( + narrow: narrow, + count: count, + allDmsCount: data.count, + pageState: pageState, + ); + }), + ])); + } +} + +class _DmItem extends StatelessWidget { + const _DmItem({ + required this.narrow, + required this.count, + required this.allDmsCount, + required this.pageState + }); + + final DmNarrow narrow; + final int count; + final int allDmsCount; + final _InboxPageState pageState; + + @override + Widget build(BuildContext context) { + final store = PerAccountStoreWidget.of(context); + final selfUser = store.users[store.account.userId]!; + + final title = switch (narrow.otherRecipientIds) { // TODO dedupe with [RecentDmConversationsItem] + [] => selfUser.fullName, + [var otherUserId] => store.users[otherUserId]?.fullName ?? '(unknown user)', + + // TODO(i18n): List formatting, like you can do in JavaScript: + // new Intl.ListFormat('ja').format(['Chris', 'Greg', 'Alya']) + // // 'Chris、Greg、Alya' + _ => narrow.otherRecipientIds.map((id) => store.users[id]?.fullName ?? '(unknown user)').join(', '), + }; + + return StickyHeaderItem( + header: _AllDmsHeaderItem( + count: allDmsCount, + collapsed: false, + pageState: pageState, + ), + allowOverflow: true, + child: Material( + color: Colors.white, + child: InkWell( + onTap: () { + Navigator.push(context, + MessageListPage.buildRoute(context: context, narrow: narrow)); + }, + // TODO min-height 48px, like other touch targets? + child: ConstrainedBox(constraints: const BoxConstraints(minHeight: 34), + child: Row(crossAxisAlignment: CrossAxisAlignment.center, children: [ + const SizedBox(width: 63), + Expanded(child: Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Text( + style: const TextStyle( + fontFamily: 'Source Sans 3', + fontSize: 17, + height: (20 / 17), + color: Color(0xFF222222), + ).merge(weightVariableTextStyle(context)), + maxLines: 2, + overflow: TextOverflow.ellipsis, + title))), + const SizedBox(width: 12), + Padding(padding: const EdgeInsetsDirectional.only(end: 16), + child: UnreadCountBadge(backgroundColor: null, + count: count)), + ]))), + ), + ); + } +} + +class _StreamHeaderItem extends _HeaderItem { + final Subscription subscription; + + const _StreamHeaderItem({ + required this.subscription, + required super.collapsed, + required super.pageState, + required super.count, + }); + + @override get title => subscription.name; + @override get icon => switch (subscription) { + // TODO these aren't quite right yet; see this message and the following: + // https://chat.zulip.org/#narrow/stream/243-mobile-team/topic/design.3A.20.23F117.20.22Inbox.22.20screen/near/1680637 + Subscription(isWebPublic: true) => ZulipIcons.globe, + Subscription(inviteOnly: true) => ZulipIcons.lock, + Subscription() => ZulipIcons.hash_sign, + }; + @override get iconColor => subscription.colorSwatch().recipientBarIcon; + @override get uncollapsedBackgroundColor => + subscription.colorSwatch().recipientBarBackground; + @override get unreadCountBadgeBackgroundColor => + subscription.colorSwatch().unreadCountBadgeBackground; + + @override get onCollapseButtonTap => () { + if (collapsed) { + pageState.uncollapseStream(subscription.streamId); + } else { + pageState.collapseStream(subscription.streamId); + } + }; + @override get onRowTap => onCollapseButtonTap; // TODO? +} + +class _StreamSection extends StatelessWidget { + const _StreamSection({ + required this.data, + required this.collapsed, + required this.pageState, + }); + + final _StreamSectionData data; + final bool collapsed; + final _InboxPageState pageState; + + @override + Widget build(BuildContext context) { + final subscription = PerAccountStoreWidget.of(context).subscriptions[data.streamId]!; + final header = _StreamHeaderItem( + subscription: subscription, + count: data.count, + collapsed: collapsed, + pageState: pageState, + ); + return StickyHeaderItem( + header: header, + child: Column(children: [ + header, + if (!collapsed) ...data.items.map((item) { + final (topic, count) = item; + return _TopicItem( + streamId: data.streamId, + topic: topic, + count: count, + streamCount: data.count, + pageState: pageState, + ); + }), + ])); + } +} + +class _TopicItem extends StatelessWidget { + const _TopicItem({ + required this.streamId, + required this.topic, + required this.count, + required this.streamCount, + required this.pageState, + }); + + final int streamId; + final String topic; + final int count; + final int streamCount; + final _InboxPageState pageState; + + @override + Widget build(BuildContext context) { + final store = PerAccountStoreWidget.of(context); + final subscription = store.subscriptions[streamId]!; + + return StickyHeaderItem( + header: _StreamHeaderItem( + subscription: subscription, + count: streamCount, + collapsed: false, + pageState: pageState, + ), + allowOverflow: true, + child: Material( + color: Colors.white, + child: InkWell( + onTap: () { + final narrow = TopicNarrow(streamId, topic); + Navigator.push(context, + MessageListPage.buildRoute(context: context, narrow: narrow)); + }, + // TODO min-height 48px, like other touch targets? + child: ConstrainedBox(constraints: const BoxConstraints(minHeight: 34), + child: Row(crossAxisAlignment: CrossAxisAlignment.center, children: [ + const SizedBox(width: 63), + Expanded(child: Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Text( + style: const TextStyle( + fontFamily: 'Source Sans 3', + fontSize: 17, + height: (20 / 17), + color: Color(0xFF222222), + ).merge(weightVariableTextStyle(context)), + maxLines: 2, + overflow: TextOverflow.ellipsis, + topic))), + const SizedBox(width: 12), + // TODO(#384) show @-mention indicator when it applies + Padding(padding: const EdgeInsetsDirectional.only(end: 16), + child: UnreadCountBadge(backgroundColor: subscription.colorSwatch(), + count: count)), + ]))))); + } +} diff --git a/lib/widgets/recent_dm_conversations.dart b/lib/widgets/recent_dm_conversations.dart index 81df4ee472..9ecbb9b0f8 100644 --- a/lib/widgets/recent_dm_conversations.dart +++ b/lib/widgets/recent_dm_conversations.dart @@ -86,7 +86,7 @@ class RecentDmConversationsItem extends StatelessWidget { final String title; final Widget avatar; - switch (narrow.otherRecipientIds) { + switch (narrow.otherRecipientIds) { // TODO dedupe with DM items in [InboxPage] case []: title = selfUser.fullName; avatar = AvatarImage(userId: selfUser.userId);