diff --git a/assets/icons/ZulipIcons.ttf b/assets/icons/ZulipIcons.ttf
index 6bf9215cd3..31f1ddd0c5 100644
Binary files a/assets/icons/ZulipIcons.ttf and b/assets/icons/ZulipIcons.ttf differ
diff --git a/assets/icons/arrow_down.svg b/assets/icons/arrow_down.svg
new file mode 100644
index 0000000000..31dc793e59
--- /dev/null
+++ b/assets/icons/arrow_down.svg
@@ -0,0 +1,3 @@
+
diff --git a/assets/icons/arrow_right.svg b/assets/icons/arrow_right.svg
new file mode 100644
index 0000000000..4cec3ed6af
--- /dev/null
+++ b/assets/icons/arrow_right.svg
@@ -0,0 +1,3 @@
+
diff --git a/assets/icons/user.svg b/assets/icons/user.svg
index 4b6ac99e8d..7598523429 100644
--- a/assets/icons/user.svg
+++ b/assets/icons/user.svg
@@ -1,15 +1,3 @@
-
-
\ No newline at end of file
+
diff --git a/lib/api/model/events.dart b/lib/api/model/events.dart
index dbaf96b8a4..a9d4eaf951 100644
--- a/lib/api/model/events.dart
+++ b/lib/api/model/events.dart
@@ -363,7 +363,9 @@ class SubscriptionUpdateEvent extends SubscriptionEvent {
final value = json['value'];
switch (SubscriptionProperty.fromRawString(json['property'] as String)) {
case SubscriptionProperty.color:
- return value as String;
+ final str = value as String;
+ assert(RegExp(r'^#[0-9a-f]{6}$').hasMatch(str));
+ return 0xff000000 | int.parse(str.substring(1), radix: 16);
case SubscriptionProperty.isMuted:
case SubscriptionProperty.inHomeView:
case SubscriptionProperty.pinToTop:
@@ -397,7 +399,10 @@ class SubscriptionUpdateEvent extends SubscriptionEvent {
/// Used in handling of [SubscriptionUpdateEvent].
@JsonEnum(fieldRename: FieldRename.snake, alwaysCreate: true)
enum SubscriptionProperty {
+ /// As an int that dart:ui's Color constructor will take:
+ ///
color,
+
isMuted,
inHomeView,
pinToTop,
diff --git a/lib/api/model/model.dart b/lib/api/model/model.dart
index 23edbb100a..730b28ff89 100644
--- a/lib/api/model/model.dart
+++ b/lib/api/model/model.dart
@@ -1,5 +1,9 @@
+import 'package:flutter/foundation.dart';
+import 'package:flutter/painting.dart';
+import 'package:flutter_color_models/flutter_color_models.dart';
import 'package:json_annotation/json_annotation.dart';
+import '../../widgets/color.dart';
import 'reaction.dart';
export 'reaction.dart';
@@ -325,7 +329,31 @@ class Subscription extends ZulipStream {
bool isMuted;
// final bool? inHomeView; // deprecated; ignore
- String color;
+ /// As an int that dart:ui's Color constructor will take:
+ ///
+ @JsonKey(readValue: _readColor)
+ int get color => _color;
+ int _color;
+ set color(int value) {
+ _color = value;
+ _swatch = null;
+ }
+ static Object? _readColor(Map json, String key) {
+ final str = (json[key] as String);
+ assert(RegExp(r'^#[0-9a-f]{6}$').hasMatch(str));
+ return 0xff000000 | int.parse(str.substring(1), radix: 16);
+ }
+
+ StreamColorSwatch? _swatch;
+ /// A [StreamColorSwatch] for the subscription, memoized.
+ // TODO I'm not sure this is the right home for this; it seems like we might
+ // instead have chosen to put it in more UI-centered code, like in a custom
+ // material [ColorScheme] class or something. But it works for now.
+ StreamColorSwatch colorSwatch() => _swatch ??= StreamColorSwatch(color);
+
+ @visibleForTesting
+ @JsonKey(includeToJson: false)
+ StreamColorSwatch? get debugCachedSwatchValue => _swatch;
Subscription({
required super.streamId,
@@ -348,8 +376,8 @@ class Subscription extends ZulipStream {
required this.audibleNotifications,
required this.pinToTop,
required this.isMuted,
- required this.color,
- });
+ required int color,
+ }) : _color = color;
factory Subscription.fromJson(Map json) =>
_$SubscriptionFromJson(json);
@@ -358,6 +386,93 @@ class Subscription extends ZulipStream {
Map toJson() => _$SubscriptionToJson(this);
}
+/// A [ColorSwatch] with colors related to a base stream color.
+///
+/// Use this in UI code for colors related to [Subscription.color],
+/// such as the background of an unread count badge.
+class StreamColorSwatch extends ColorSwatch<_StreamColorVariant> {
+ StreamColorSwatch(int base) : super(base, _compute(base));
+
+ Color get base => this[_StreamColorVariant.base]!;
+
+ Color get unreadCountBadgeBackground => this[_StreamColorVariant.unreadCountBadgeBackground]!;
+
+ /// The stream icon on a plain-colored surface, such as white.
+ ///
+ /// For the icon on a [barBackground]-colored surface,
+ /// use [iconOnBarBackground] instead.
+ Color get iconOnPlainBackground => this[_StreamColorVariant.iconOnPlainBackground]!;
+
+ /// The stream icon on a [barBackground]-colored surface.
+ ///
+ /// For the icon on a plain surface, use [iconOnPlainBackground] instead.
+ /// This color is chosen to enhance contrast with [barBackground]:
+ ///
+ Color get iconOnBarBackground => this[_StreamColorVariant.iconOnBarBackground]!;
+
+ /// The background color of a bar representing a stream, like a recipient bar.
+ ///
+ /// Use this in the message list, the "Inbox" view, and the "Streams" view.
+ Color get barBackground => this[_StreamColorVariant.barBackground]!;
+
+ static Map<_StreamColorVariant, Color> _compute(int base) {
+ final baseAsColor = Color(base);
+
+ final clamped20to75 = clampLchLightness(baseAsColor, 20, 75);
+ final clamped20to75AsHsl = HSLColor.fromColor(clamped20to75);
+
+ return {
+ _StreamColorVariant.base: baseAsColor,
+
+ // Follows `.unread-count` in Vlad's replit:
+ //
+ //
+ //
+ // TODO fix bug where our results differ from the replit's (see unit tests)
+ _StreamColorVariant.unreadCountBadgeBackground:
+ clampLchLightness(baseAsColor, 30, 70)
+ .withOpacity(0.3),
+
+ // Follows `.sidebar-row__icon` in Vlad's replit:
+ //
+ //
+ // TODO fix bug where our results differ from the replit's (see unit tests)
+ _StreamColorVariant.iconOnPlainBackground: clamped20to75,
+
+ // Follows `.recepeient__icon` in Vlad's replit:
+ //
+ //
+ //
+ // TODO fix bug where our results differ from the replit's (see unit tests)
+ _StreamColorVariant.iconOnBarBackground:
+ clamped20to75AsHsl
+ .withLightness(clamped20to75AsHsl.lightness - 0.12)
+ .toColor(),
+
+ // 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?
+ // TODO fix bug where our results differ from the replit's (see unit tests)
+ _StreamColorVariant.barBackground:
+ LabColor.fromColor(const Color(0xfff9f9f9))
+ .interpolate(LabColor.fromColor(clamped20to75), 0.22)
+ .toColor(),
+ };
+ }
+}
+
+enum _StreamColorVariant {
+ base,
+ unreadCountBadgeBackground,
+ iconOnPlainBackground,
+ iconOnBarBackground,
+ barBackground,
+}
+
/// As in the get-messages response.
///
/// https://zulip.com/api/get-messages#response
diff --git a/lib/api/model/model.g.dart b/lib/api/model/model.g.dart
index ecc8b11d48..2269da8673 100644
--- a/lib/api/model/model.g.dart
+++ b/lib/api/model/model.g.dart
@@ -210,7 +210,7 @@ Subscription _$SubscriptionFromJson(Map json) => Subscription(
audibleNotifications: json['audible_notifications'] as bool?,
pinToTop: json['pin_to_top'] as bool,
isMuted: json['is_muted'] as bool,
- color: json['color'] as String,
+ color: Subscription._readColor(json, 'color') as int,
);
Map _$SubscriptionToJson(Subscription instance) =>
diff --git a/lib/model/store.dart b/lib/model/store.dart
index fc8d164808..27187dc5f5 100644
--- a/lib/model/store.dart
+++ b/lib/model/store.dart
@@ -323,7 +323,7 @@ class PerAccountStore extends ChangeNotifier {
if (subscription == null) return; // TODO(log)
switch (event.property) {
case SubscriptionProperty.color:
- subscription.color = event.value as String;
+ subscription.color = event.value as int;
case SubscriptionProperty.isMuted:
subscription.isMuted = event.value as bool;
case SubscriptionProperty.inHomeView:
diff --git a/lib/model/unreads.dart b/lib/model/unreads.dart
index b9938d61bf..06d13fe3ff 100644
--- a/lib/model/unreads.dart
+++ b/lib/model/unreads.dart
@@ -81,10 +81,10 @@ class Unreads extends ChangeNotifier {
// TODO excluded for now; would need to handle nuances around muting etc.
// int count;
- /// Unread stream messages, as: stream ID → topic → message ID.
+ /// Unread stream messages, as: stream ID → topic → message IDs (sorted).
final Map>> streams;
- /// Unread DM messages, as: DM narrow → message ID.
+ /// Unread DM messages, as: DM narrow → message IDs (sorted).
final Map> dms;
/// Unread messages with the self-user @-mentioned, directly or by wildcard.
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/color.dart b/lib/widgets/color.dart
new file mode 100644
index 0000000000..85fd60ba18
--- /dev/null
+++ b/lib/widgets/color.dart
@@ -0,0 +1,18 @@
+import 'dart:ui';
+
+import 'package:flutter_color_models/flutter_color_models.dart';
+
+// This function promises to deal with "LCH" lightness, not "LAB" lightness,
+// but it's not yet true. We haven't found a Dart libary that can work with LCH:
+//
+// We use LAB because some quick reading suggests that the "L" axis
+// is the same in both representations:
+//
+//
+// TODO try LCH; see linked discussion
+Color clampLchLightness(Color color, num lowerLimit, num upperLimit) {
+ final asLab = LabColor.fromColor(color);
+ return asLab
+ .copyWith(lightness: asLab.lightness.clamp(lowerLimit, upperLimit))
+ .toColor();
+}
diff --git a/lib/widgets/icons.dart b/lib/widgets/icons.dart
index b9553c643d..84cb8a7fc1 100644
--- a/lib/widgets/icons.dart
+++ b/lib/widgets/icons.dart
@@ -22,38 +22,44 @@ abstract final class ZulipIcons {
//
// BEGIN GENERATED ICON DATA
+ /// The Zulip custom icon "arrow_down".
+ static const IconData arrow_down = IconData(0xf101, fontFamily: "Zulip Icons");
+
+ /// The Zulip custom icon "arrow_right".
+ static const IconData arrow_right = IconData(0xf102, fontFamily: "Zulip Icons");
+
/// The Zulip custom icon "bot".
- static const IconData bot = IconData(0xf101, fontFamily: "Zulip Icons");
+ static const IconData bot = IconData(0xf103, fontFamily: "Zulip Icons");
/// The Zulip custom icon "globe".
- static const IconData globe = IconData(0xf102, fontFamily: "Zulip Icons");
+ static const IconData globe = IconData(0xf104, fontFamily: "Zulip Icons");
/// The Zulip custom icon "group_dm".
- static const IconData group_dm = IconData(0xf103, fontFamily: "Zulip Icons");
+ static const IconData group_dm = IconData(0xf105, fontFamily: "Zulip Icons");
/// The Zulip custom icon "hash_sign".
- static const IconData hash_sign = IconData(0xf104, fontFamily: "Zulip Icons");
+ static const IconData hash_sign = IconData(0xf106, fontFamily: "Zulip Icons");
/// The Zulip custom icon "language".
- static const IconData language = IconData(0xf105, fontFamily: "Zulip Icons");
+ static const IconData language = IconData(0xf107, fontFamily: "Zulip Icons");
/// The Zulip custom icon "lock".
- static const IconData lock = IconData(0xf106, fontFamily: "Zulip Icons");
+ static const IconData lock = IconData(0xf108, fontFamily: "Zulip Icons");
/// The Zulip custom icon "mute".
- static const IconData mute = IconData(0xf107, fontFamily: "Zulip Icons");
+ static const IconData mute = IconData(0xf109, fontFamily: "Zulip Icons");
/// The Zulip custom icon "read_receipts".
- static const IconData read_receipts = IconData(0xf108, fontFamily: "Zulip Icons");
+ static const IconData read_receipts = IconData(0xf10a, fontFamily: "Zulip Icons");
/// The Zulip custom icon "topic".
- static const IconData topic = IconData(0xf109, fontFamily: "Zulip Icons");
+ static const IconData topic = IconData(0xf10b, fontFamily: "Zulip Icons");
/// The Zulip custom icon "unmute".
- static const IconData unmute = IconData(0xf10a, fontFamily: "Zulip Icons");
+ static const IconData unmute = IconData(0xf10c, fontFamily: "Zulip Icons");
/// The Zulip custom icon "user".
- static const IconData user = IconData(0xf10b, fontFamily: "Zulip Icons");
+ static const IconData user = IconData(0xf10d, fontFamily: "Zulip Icons");
// END GENERATED ICON DATA
}
diff --git a/lib/widgets/inbox.dart b/lib/widgets/inbox.dart
new file mode 100644
index 0000000000..b5f5dad94b
--- /dev/null
+++ b/lib/widgets/inbox.dart
@@ -0,0 +1,508 @@
+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);
+ final subscriptions = store.subscriptions;
+
+ // 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));
+ }
+
+ final sortedUnreadStreams = unreadsModel!.streams.entries
+ // 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
+ // choosing the stream icon.
+ .where((entry) => subscriptions.containsKey(entry.key))
+ .toList()
+ ..sort((a, b) {
+ final subA = subscriptions[a.key]!;
+ final subB = subscriptions[b.key]!;
+
+ // TODO "pin" icon on the stream row? dividers in the list?
+ if (subA.pinToTop != subB.pinToTop) {
+ return subA.pinToTop ? -1 : 1;
+ }
+
+ // TODO(i18n) something like JS's String.prototype.localeCompare
+ return subA.name.toLowerCase().compareTo(subB.name.toLowerCase());
+ });
+
+ for (final MapEntry(key: streamId, value: topics) in sortedUnreadStreams) {
+ final topicItems = <(String, int, int)>[];
+ int countInStream = 0;
+ for (final MapEntry(key: topic, value: messageIds) in topics.entries) {
+ final countInTopic = messageIds.length;
+ topicItems.add((topic, countInTopic, messageIds.last));
+ countInStream += countInTopic;
+ }
+ if (countInStream == 0) {
+ continue;
+ }
+ topicItems.sort((a, b) {
+ final (_, _, aLastUnreadId) = a;
+ final (_, _, bLastUnreadId) = b;
+ return bLastUnreadId.compareTo(aLastUnreadId);
+ });
+ sections.add(_StreamSectionData(streamId, countInStream, topicItems));
+ }
+
+ // TODO(#346) Filter out muted messages.
+ // (Eventually let the user toggle that filtering?)
+
+ 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, 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 collapsedIconColor;
+ Color get uncollapsedIconColor;
+ 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(
+ // TODO use onRowTap to handle taps that are not on the collapse button.
+ // Probably we should give the collapse button a 44px or 48px square
+ // touch target:
+ //
+ // But that's in tension with the Figma, which gives these header rows
+ // 40px min height.
+ onTap: onCollapseButtonTap,
+ 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: collapsed ? collapsedIconColor : uncollapsedIconColor,
+ 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 collapsedIconColor => const Color(0xFF222222);
+ @override get uncollapsedIconColor => const Color(0xFF222222);
+ @override get uncollapsedBackgroundColor => const Color(0xFFF3F0E7);
+ @override get unreadCountBadgeBackgroundColor => null;
+
+ @override get onCollapseButtonTap => () {
+ pageState.allDmsCollapsed = !collapsed;
+ };
+ @override get onRowTap => onCollapseButtonTap; // TODO open all-DMs narrow?
+}
+
+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', 'Shu'])
+ // // 'Chris、Greg、Alya、Shu'
+ _ => 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));
+ },
+ 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 icons 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 collapsedIconColor => subscription.colorSwatch().iconOnPlainBackground;
+ @override get uncollapsedIconColor => subscription.colorSwatch().iconOnBarBackground;
+ @override get uncollapsedBackgroundColor =>
+ subscription.colorSwatch().barBackground;
+ @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 open stream narrow
+}
+
+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));
+ },
+ 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/message_list.dart b/lib/widgets/message_list.dart
index b2d1dbf1c8..daa68cf86f 100644
--- a/lib/widgets/message_list.dart
+++ b/lib/widgets/message_list.dart
@@ -508,13 +508,6 @@ class _UnreadMarker extends StatelessWidget {
}
}
-Color colorForStream(Subscription? subscription) {
- final color = subscription?.color;
- if (color == null) return const Color(0x00c2c2c2);
- assert(RegExp(r'^#[0-9a-f]{6}$').hasMatch(color));
- return Color(0xff000000 | int.parse(color.substring(1), radix: 16));
-}
-
class StreamTopicRecipientHeader extends StatelessWidget {
const StreamTopicRecipientHeader({super.key, required this.message});
@@ -529,7 +522,7 @@ class StreamTopicRecipientHeader extends StatelessWidget {
final topic = message.subject;
final subscription = store.subscriptions[message.streamId];
- final streamColor = colorForStream(subscription);
+ final streamColor = Color(subscription?.color ?? 0x00c2c2c2);
final contrastingColor =
ThemeData.estimateBrightnessForColor(streamColor) == Brightness.dark
? Colors.white
diff --git a/lib/widgets/recent_dm_conversations.dart b/lib/widgets/recent_dm_conversations.dart
index 0122deb999..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);
@@ -134,7 +134,7 @@ class RecentDmConversationsItem extends StatelessWidget {
const SizedBox(width: 12),
unreadCount > 0
? Padding(padding: const EdgeInsetsDirectional.only(end: 16),
- child: UnreadCountBadge(baseStreamColor: null,
+ child: UnreadCountBadge(backgroundColor: null,
count: unreadCount))
: const SizedBox(),
]))));
diff --git a/lib/widgets/unread_count_badge.dart b/lib/widgets/unread_count_badge.dart
index 6a05b06d48..cc654eef37 100644
--- a/lib/widgets/unread_count_badge.dart
+++ b/lib/widgets/unread_count_badge.dart
@@ -1,8 +1,8 @@
import 'dart:ui';
-import 'package:flutter_color_models/flutter_color_models.dart';
import 'package:flutter/material.dart';
+import '../api/model/model.dart';
import 'text.dart';
/// A widget to display a given number of unreads in a conversation.
@@ -13,57 +13,33 @@ class UnreadCountBadge extends StatelessWidget {
const UnreadCountBadge({
super.key,
required this.count,
- required this.baseStreamColor,
+ required this.backgroundColor,
this.bold = false,
});
final int count;
final bool bold;
- /// A base stream color, from a stream subscription in user data, or null.
+ /// The badge's background color.
///
- /// If not null, the background will be colored with an appropriate
- /// transformation of this.
+ /// Pass a [StreamColorSwatch] if this badge represents messages in one
+ /// specific stream. The appropriate color from the swatch will be used.
///
/// If null, the default neutral background will be used.
- final Color? baseStreamColor;
-
- @visibleForTesting
- Color getBackgroundColor() {
- if (baseStreamColor == null) {
- return const Color.fromRGBO(102, 102, 153, 0.15);
- }
-
- // Follows `.unread-count` in Vlad's replit:
- //
- //
-
- // The design uses "LCH", not "LAB", but we haven't found a Dart libary
- // that can work with LCH:
- //
- //
- // We use LAB because some quick reading suggests that the "L" axis
- // is the same in both representations:
- //
- // and because the design doesn't use the LCH representation except to
- // adjust an "L" value.
- //
- // TODO try LCH; see linked discussion
- // TODO fix bug where our results differ from the replit's (see unit tests)
- // TODO profiling for expensive computation
- final asLab = LabColor.fromColor(baseStreamColor!);
- return asLab
- .copyWith(lightness: asLab.lightness.clamp(30, 70))
- .toColor()
- .withOpacity(0.3);
- }
+ final Color? backgroundColor;
@override
Widget build(BuildContext context) {
+ final effectiveBackgroundColor = switch (backgroundColor) {
+ StreamColorSwatch(unreadCountBadgeBackground: var color) => color,
+ Color() => backgroundColor,
+ null => const Color.fromRGBO(102, 102, 153, 0.15),
+ };
+
return DecoratedBox(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(3),
- color: getBackgroundColor(),
+ color: effectiveBackgroundColor,
),
child: Padding(
padding: const EdgeInsets.fromLTRB(4, 0, 4, 1),
diff --git a/test/api/model/events_checks.dart b/test/api/model/events_checks.dart
index 038abadb0a..cffa2b4074 100644
--- a/test/api/model/events_checks.dart
+++ b/test/api/model/events_checks.dart
@@ -19,6 +19,10 @@ extension SubscriptionRemoveEventChecks on Subject {
Subject> get streamIds => has((e) => e.streamIds, 'streamIds');
}
+extension SubscriptionUpdateEventChecks on Subject {
+ Subject