diff --git a/lib/widgets/app.dart b/lib/widgets/app.dart index 771fd4f53c..322443a47b 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'; @@ -249,6 +250,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")), + 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..9270801914 --- /dev/null +++ b/lib/widgets/inbox.dart @@ -0,0 +1,518 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_color_models/flutter_color_models.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 'colors.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 = 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 elsewhere: we reset a collapsible + // row's collapsed state when it's cleared of unreads. + collapsedStreamIds.removeWhere((streamId) => !unreadsModel!.streams.containsKey(streamId)); + if (unreadsModel!.dms.isEmpty) { + allDmsCollapsed = false; + } + }); + } + + @override + Widget build(BuildContext context) { + final store = PerAccountStoreWidget.of(context); + + final sections = []; + + // 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)); + } + + 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(:var count): + final header = AllDmsHeaderItem( + count: count, + collapsed: allDmsCollapsed, + pageState: this, + ); + return StickyHeaderItem(header: header, + child: AllDmsSection( + data: section, + collapsed: allDmsCollapsed, + pageState: this, + )); + case StreamSectionData(:var streamId, :var count): + final collapsed = collapsedStreamIds.contains(streamId); + final header = StreamHeaderItem( + streamId: streamId, + count: count, + collapsed: collapsed, + pageState: this, + ); + return StickyHeaderItem(header: header, + child: 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); +} + +class AllDmsHeaderItem extends StatelessWidget { + const AllDmsHeaderItem({ + super.key, + required this.count, + required this.collapsed, + required this.pageState, + }); + + final int count; + final bool collapsed; + final InboxPageState pageState; + + @override + Widget build(BuildContext context) { + return Material( + color: collapsed ? Colors.white : const Color(0xFFF3F0E7), + child: InkWell( + onTap: () { + pageState.allDmsCollapsed = !collapsed; + }, + // TODO min-height 48px, like other touch targets? + child: Row(crossAxisAlignment: CrossAxisAlignment.center, children: [ + Padding(padding: const EdgeInsets.all(10), + child: Icon(size: 20, color: const Color(0x7F1D2E48), + collapsed ? ZulipIcons.arrow_right : ZulipIcons.arrow_down)), + const Icon(size: 18, color: Color(0xFF222222), + ZulipIcons.user), + 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, + 'Direct messages'))), + const SizedBox(width: 12), + Padding(padding: const EdgeInsetsDirectional.only(end: 16), + child: UnreadCountBadge(baseStreamColor: null, bold: true, + count: count)), + ])), + ); + } +} + +class AllDmsSection extends StatelessWidget { + const AllDmsSection({ + super.key, + required this.data, + required this.collapsed, + required this.pageState, + }); + + final AllDmsSectionData data; + final bool collapsed; + final InboxPageState pageState; + + @override + Widget build(BuildContext context) { + return Column(children: [ + AllDmsHeaderItem(count: data.count, collapsed: collapsed, pageState: pageState), + 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({ + super.key, + 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) { + [] => 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(baseStreamColor: null, + count: count)), + ]))), + ), + ); + } +} + +class StreamHeaderItem extends StatelessWidget { + const StreamHeaderItem({ + super.key, + required this.streamId, + required this.count, + required this.collapsed, + required this.pageState, + }); + + final int streamId; + final int count; + final bool collapsed; + final InboxPageState pageState; + + @override + Widget build(BuildContext context) { + final store = PerAccountStoreWidget.of(context); + final stream = store.streams[streamId]!; + final subscription = store.subscriptions[streamId]!; + + final clampedColor = clampLchLightness(Color(subscription.color), 20, 75); + + // Follows `.recepeient__icon` in Vlad's replit, except (to match web) + // with a `.darken(0.12)` omitted: + // + // + // + // TODO await decision on that `.darken(0.12)` or another way to raise contrast: + // https://chat.zulip.org/#narrow/stream/101-design/topic/UI.20redesign.3A.20recipient.20bar.20colors/near/1675786 + final iconColor = clampedColor; + + // Follows `.recepient` in Vlad's replit: + // + // + // TODO I think [LabColor.interpolate] doesn't actually do LAB mixing; + // it just calls up to the superclass method [ColorModel.interpolate]: + // + // which does ordinary RGB mixing. Investigate and send a PR? + final barColor = LabColor.fromColor(const Color(0xfff9f9f9)) + .interpolate(LabColor.fromColor(clampedColor), 0.22); + return Material( + color: collapsed ? Colors.white : barColor, + child: InkWell( + onTap: () { + if (collapsed) { + pageState.uncollapseStream(streamId); + } else { + pageState.collapseStream(streamId); + } + }, + // TODO min-height 48px, like other touch targets? + child: Row(crossAxisAlignment: CrossAxisAlignment.center, children: [ + Padding(padding: const EdgeInsets.all(10), + child: Icon(size: 20, color: const Color(0x7F1D2E48), + collapsed ? ZulipIcons.arrow_right : ZulipIcons.arrow_down)), + Icon(size: 18, + color: iconColor, + switch (stream) { + ZulipStream(isWebPublic: true) => ZulipIcons.globe, + ZulipStream(inviteOnly: true) => ZulipIcons.lock, + ZulipStream() => ZulipIcons.hash_sign, + }), + 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, + store.streams[streamId]!.name))), + const SizedBox(width: 12), + Padding(padding: const EdgeInsetsDirectional.only(end: 16), + child: UnreadCountBadge( + baseStreamColor: Color(store.subscriptions[streamId]!.color), + bold: true, + count: count, + )), + ])), + ); + } +} + +class StreamSection extends StatelessWidget { + const StreamSection({ + super.key, + required this.data, + required this.collapsed, + required this.pageState, + }); + + final StreamSectionData data; + final bool collapsed; + final InboxPageState pageState; + + @override + Widget build(BuildContext context) { + return Column(children: [ + StreamHeaderItem( + streamId: data.streamId, + count: data.count, + collapsed: collapsed, + pageState: pageState, + ), + 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({ + super.key, + 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); + + return StickyHeaderItem( + header: StreamHeaderItem( + streamId: streamId, + 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), + Padding(padding: const EdgeInsetsDirectional.only(end: 16), + child: UnreadCountBadge( + baseStreamColor: Color(store.subscriptions[streamId]!.color), + count: count, + )), + ]))), + ), + ); + } +}